diff --git a/frontend/src/app/pages/Workflows/app/CalendarView.tsx b/frontend/src/app/pages/Workflows/app/CalendarView.tsx index caa91227..2de5ce34 100644 --- a/frontend/src/app/pages/Workflows/app/CalendarView.tsx +++ b/frontend/src/app/pages/Workflows/app/CalendarView.tsx @@ -2,7 +2,8 @@ import React, { useMemo, useRef, useEffect, useLayoutEffect, useState } from 're import { createPortal } from 'react-dom'; import type { CSSProperties } from 'react'; import { useAppSelector } from '@/shared/hooks'; -import { fireTimesWithin, startOfWeek, startOfMonthGrid, addDays, sameDay } from '@/app/pages/Workflows/scheduleUtils'; +import { startOfWeek, startOfMonthGrid, addDays, sameDay } from '@/app/pages/Workflows/scheduleUtils'; +import { useCalendarOccurrences } from './useCalendarOccurrences'; import { colorForWorkflow, useWC, type WCPalette } from './uiKit'; import type { AppNav } from './types'; @@ -36,24 +37,34 @@ const tabBtn = (active: boolean, WC: WCPalette): CSSProperties => ({ const CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => { const WC = useWC(); const items = useAppSelector((s) => s.workflows.items); - const now = new Date(); + // Tick the clock so the now-line and "today" highlight stay live instead of + // freezing at first render. + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 60000); + return () => clearInterval(id); + }, []); const ref = nav.refDate; + const refKey = `${ref.getFullYear()}-${ref.getMonth()}-${ref.getDate()}`; // Window of occurrences spanning the visible month grid (covers week too). - const occ = useMemo(() => { + // Fired times come from the backend's recurrence engine, not a JS reimpl, so + // the grid matches what actually runs (timezone + last-day-of-month aware). + const { fromIso, toIso } = useMemo(() => { const from = startOfMonthGrid(ref); - const to = addDays(from, 42); + return { fromIso: from.toISOString(), toIso: addDays(from, 42).toISOString() }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refKey]); + const { events } = useCalendarOccurrences(fromIso, toIso); + const occ = useMemo(() => { const out: Occ[] = []; - for (const wf of Object.values(items)) { - if (wf.unsaved) continue; - const color = colorForWorkflow(wf); - for (const at of fireTimesWithin(wf, from, to, 200)) { - out.push({ wfId: wf.id, title: wf.title || 'Untitled', at, color }); - } + for (const e of events) { + const wf = items[e.workflowId]; + if (!wf || wf.unsaved) continue; + out.push({ wfId: wf.id, title: wf.title || 'Untitled', at: e.at, color: colorForWorkflow(wf) }); } return out.sort((a, b) => a.at.getTime() - b.at.getTime()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items, ref]); + }, [events, items]); const occByDay = useMemo(() => { const map = new Map(); diff --git a/frontend/src/app/pages/Workflows/app/HomeView.tsx b/frontend/src/app/pages/Workflows/app/HomeView.tsx index 449c0e63..5c2c4401 100644 --- a/frontend/src/app/pages/Workflows/app/HomeView.tsx +++ b/frontend/src/app/pages/Workflows/app/HomeView.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { runMissedRuns, dismissMissedRuns } from '@/shared/state/missedRunsSlice'; import { openWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice'; -import { fireTimesWithin } from '@/app/pages/Workflows/scheduleUtils'; +import { useCalendarOccurrences } from './useCalendarOccurrences'; import { colorForWorkflow, useWC, statusChip, statusDot } from './uiKit'; import { clockOf, whenText } from './model'; import type { AppNav } from './types'; @@ -10,10 +10,6 @@ import type { AppNav } from './types'; interface ComingRun { wfId: string; title: string; time: string; sortKey: number; steps: number; color: string; } interface ComingGroup { key: string; dayNum: number; dow: string; runs: ComingRun[]; countLabel: string; } const COMING_CAP = 3; -// Enough fires to cover the whole 7-day window even at the 15-min floor -// (7 * 24 * 4 = 672), so each day's "N runs" count is the real total, not -// wherever a small global cap happened to run out. -const COMING_FIRE_CAP = 700; const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => { const WC = useWC(); @@ -45,19 +41,27 @@ const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => { }; }), [active, items, allRuns]); + // Fetch the 7-day window from the backend's recurrence engine (single source + // of truth) instead of recomputing fire times in JS. + const dayKey = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`; + const { fromIso, toIso } = useMemo(() => { + const from = new Date(now); from.setHours(0, 0, 0, 0); + return { fromIso: from.toISOString(), toIso: new Date(from.getTime() + 7 * 86400000).toISOString() }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dayKey]); + const { events } = useCalendarOccurrences(fromIso, toIso); const comingGroups = useMemo(() => { const from = new Date(now); from.setHours(0, 0, 0, 0); - const to = new Date(from.getTime() + 7 * 86400000); const byDay = new Map(); - for (const wf of Object.values(items)) { - if (wf.unsaved) continue; - const fires = fireTimesWithin(wf, now, to, COMING_FIRE_CAP); - for (const f of fires) { - const key = `${f.getFullYear()}-${f.getMonth()}-${f.getDate()}`; - const arr = byDay.get(key) || []; - arr.push({ wfId: wf.id, title: wf.title || 'Untitled', time: clockOf(f), sortKey: f.getTime(), steps: wf.steps.length, color: colorForWorkflow(wf) }); - byDay.set(key, arr); - } + for (const e of events) { + if (e.at.getTime() < now.getTime()) continue; // upcoming only + const wf = items[e.workflowId]; + if (!wf || wf.unsaved) continue; + const f = e.at; + const key = `${f.getFullYear()}-${f.getMonth()}-${f.getDate()}`; + const arr = byDay.get(key) || []; + arr.push({ wfId: wf.id, title: wf.title || 'Untitled', time: clockOf(f), sortKey: f.getTime(), steps: wf.steps.length, color: colorForWorkflow(wf) }); + byDay.set(key, arr); } const groups: ComingGroup[] = []; for (let i = 0; i < 7; i += 1) { @@ -68,7 +72,7 @@ const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => { } return groups; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items]); + }, [events, items]); const recents = useMemo(() => allRuns.slice(0, 8).map((r) => ({ id: r.id, diff --git a/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx b/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx index eb516a16..84c0ab9a 100644 --- a/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx +++ b/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import type { CSSProperties } from 'react'; import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice'; -import { describeSchedule } from '@/app/pages/Workflows/scheduleUtils'; +import { describeSchedule, needsScheduleTestWarning } from '@/app/pages/Workflows/scheduleUtils'; import { useWC, FONT_SERIF, track, knob } from './uiKit'; import { freqOf, patchForFreq, intervalMinutes, timeInputValue, parseTimeInput, ordinal, nextRunText, type Freq, @@ -89,6 +89,13 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { + {enabled && needsScheduleTestWarning(workflow) && ( +
+ + Not test-run yet. A scheduled run can't pause for permission prompts, so if this needs tool access it may fail silently. Hit Run once to grant access. +
+ )} +
{FREQS.map(([k, label]) => ( @@ -189,7 +196,7 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
- Next run {nextRunText(workflow)} + Next run {nextRunText(workflow, workflow.next_run_at ? new Date(workflow.next_run_at) : null)}
{maxRuns != null && (
diff --git a/frontend/src/app/pages/Workflows/app/model.ts b/frontend/src/app/pages/Workflows/app/model.ts index e2327ab7..c0c24d44 100644 --- a/frontend/src/app/pages/Workflows/app/model.ts +++ b/frontend/src/app/pages/Workflows/app/model.ts @@ -1,6 +1,6 @@ import type { Workflow, WorkflowRun, ScheduleConfig, ActiveRun } from '@/shared/state/workflowsSlice'; import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; -import { isScheduleActive, fireTimesWithin } from '@/app/pages/Workflows/scheduleUtils'; +import { isScheduleActive } from '@/app/pages/Workflows/scheduleUtils'; // The design speaks in four cadence buckets; the backend speaks in repeat_unit. // These two functions are the only place the two vocabularies meet. @@ -53,21 +53,13 @@ export function isRunning(wf: Workflow, active: ActiveRun[]): boolean { return wf.last_run_status === 'running' || active.some((a) => a.workflow_id === wf.id); } -export function previewNextRun(wf: Workflow): Date | null { - if (!isScheduleActive(wf.schedule)) return null; - const now = new Date(); - const horizon = new Date(now.getTime() + 366 * 86400000); - const fires = fireTimesWithin(wf, now, horizon, 1); - return fires[0] ?? null; -} - const TIME_OPTS: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' }; export function clockOf(date: Date): string { return date.toLocaleTimeString([], TIME_OPTS).toLowerCase().replace(' ', ''); } -// "today", "tomorrow", or "Mon Jun 23" — for next-run and coming-up labels. +// "today", "tomorrow", or "Mon Jun 23" for next-run and coming-up labels. export function relativeDayLabel(date: Date, now = new Date()): string { const a = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const b = new Date(now.getFullYear(), now.getMonth(), now.getDate()); @@ -78,10 +70,10 @@ export function relativeDayLabel(date: Date, now = new Date()): string { return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); } -export function nextRunText(wf: Workflow): string { - if (!isScheduleActive(wf.schedule)) return '— paused'; - const next = previewNextRun(wf); - if (!next) return '—'; +// `next` is the backend-computed next_run_at (authoritative), not a JS reimpl. +export function nextRunText(wf: Workflow, next: Date | null): string { + if (!isScheduleActive(wf.schedule)) return 'Paused'; + if (!next) return 'None scheduled'; return `${relativeDayLabel(next)} at ${clockOf(next)}`; } diff --git a/frontend/src/app/pages/Workflows/app/useCalendarOccurrences.ts b/frontend/src/app/pages/Workflows/app/useCalendarOccurrences.ts new file mode 100644 index 00000000..b7cb3e0b --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/useCalendarOccurrences.ts @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useAppSelector } from '@/shared/hooks'; +import { API_BASE } from '@/shared/config'; + +export interface CalendarOccurrence { + workflowId: string; + at: Date; +} + +// Single source of truth for "when does a workflow fire": the backend's +// timezone-aware recurrence engine via /workflows/calendar. The frontend used +// to recompute this in JS (timezone-naive, missing last-day-of-month), so the +// calendar disagreed with what actually ran. Fetch the real instants instead. +// +// Keeps the current events until a fetch for a NEW window lands so a schedule +// edit (same window, new fingerprint) refetches without blanking; only a +// window change clears, since those events are for the wrong span. +export function useCalendarOccurrences(fromIso: string, toIso: string): { + events: CalendarOccurrence[]; + loaded: boolean; +} { + const items = useAppSelector((s) => s.workflows.items); + // Fingerprint only the fields that change which occurrences exist. NOT + // updated_at: the scheduler bumps it every tick and would churn the fetch. + const scheduleKey = useMemo( + () => Object.values(items) + .map((w) => `${w.id}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.day_of_month ?? ''}:${w.schedule.last_day_of_month ?? ''}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`) + .sort() + .join('|'), + [items], + ); + + const windowKey = `${fromIso}:${toIso}`; + const requestKey = `${windowKey}:${scheduleKey}`; + const [events, setEvents] = useState([]); + const [fetchedWindowKey, setFetchedWindowKey] = useState(''); + + useEffect(() => { + // No AbortController: the global fetch interceptor dedupes GETs by URL onto + // one request, so aborting on re-run (as workflows hydrate) would reject the + // shared request. The `cancelled` guard stops stale state writes instead. + let cancelled = false; + fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`) + .then((res) => { + if (!res.ok) throw new Error(`calendar failed ${res.status}`); + return res.json(); + }) + .then((data: { events?: { workflow_id: string; fire_at: string }[] }) => { + if (cancelled) return; + const parsed: CalendarOccurrence[] = []; + for (const e of data.events || []) { + const at = new Date(e.fire_at); + if (!Number.isNaN(at.getTime())) parsed.push({ workflowId: e.workflow_id, at }); + } + setEvents(parsed); + setFetchedWindowKey(windowKey); + }) + .catch(() => { + if (cancelled) return; + setFetchedWindowKey(windowKey); + }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fromIso, toIso, requestKey]); + + // Events from a previous window are for the wrong span: hide them until the + // new window's fetch resolves. + if (fetchedWindowKey !== windowKey) return { events: [], loaded: false }; + return { events, loaded: true }; +} diff --git a/frontend/src/app/pages/Workflows/scheduleUtils.ts b/frontend/src/app/pages/Workflows/scheduleUtils.ts index 1e7b2c9a..c26075cb 100644 --- a/frontend/src/app/pages/Workflows/scheduleUtils.ts +++ b/frontend/src/app/pages/Workflows/scheduleUtils.ts @@ -130,109 +130,3 @@ export function addDays(date: Date, n: number): Date { d.setDate(d.getDate() + n); return d; } - -function lastDayOfMonth(year: number, monthZeroBased: number): number { - // Date(year, month, 0) returns the last day of the previous month, so - // passing month+1 gives the last day of `monthZeroBased`. Matches the - // backend's calendar.monthrange behavior so the FE preview no longer - // clamps to day 28 (the old shared bug between this and previewNextRun). - return new Date(year, monthZeroBased + 1, 0).getDate(); -} - -export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] { - const sched = workflow.schedule; - if (!isScheduleActive(sched)) return []; - // Honor end conditions on the FE preview too, so the calendar doesn't - // paint pills for fires the backend will refuse to run. ends_at is an - // ISO string in workflow state; max_runs/runs_count are numbers. - if (sched.ends_at) { - const endsAt = new Date(sched.ends_at); - if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() <= from.getTime()) return []; - if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() < to.getTime()) to = endsAt; - } - // Don't paint fires for days that predate the workflow itself. A - // workflow created this Wednesday shouldn't show pills on Sun/Mon/Tue - // of the same week. created_at is an ISO string; only floor on success. - if (workflow.created_at) { - const createdAt = new Date(workflow.created_at); - if (!Number.isNaN(createdAt.getTime()) && createdAt.getTime() > from.getTime()) { - from = createdAt; - } - } - if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return []; - const remainingRuns = sched.max_runs != null ? Math.max(0, sched.max_runs - sched.runs_count) : Infinity; - const effectiveCap = Math.min(cap, remainingRuns); - if (effectiveCap === 0) return []; - const out: Date[] = []; - const cursor = new Date(from); - cursor.setHours(0, 0, 0, 0); - - if (sched.repeat_unit === 'minute') { - const step = Math.max(15, sched.repeat_every); - const d = new Date(from); - d.setSeconds(0, 0); - for (; d <= to && out.length < effectiveCap; d.setTime(d.getTime() + step * 60000)) { - if (d >= from) out.push(new Date(d)); - } - return out; - } - - if (sched.repeat_unit === 'hour') { - const step = Math.max(1, sched.repeat_every); - const d = new Date(from); - d.setMinutes(sched.minute, 0, 0); - for (; d <= to && out.length < effectiveCap; d.setTime(d.getTime() + step * 3600000)) { - if (d >= from) out.push(new Date(d)); - } - return out; - } - - if (sched.repeat_unit === 'day') { - const step = Math.max(1, sched.repeat_every); - for (let i = 0; i < 366 && out.length < effectiveCap; i += step) { - const d = new Date(cursor); - d.setDate(d.getDate() + i); - d.setHours(sched.hour, sched.minute, 0, 0); - if (d >= from && d <= to) out.push(d); - if (d > to) break; - } - return out; - } - - if (sched.repeat_unit === 'month') { - const startDay = sched.day_of_month || from.getDate(); - let year = from.getFullYear(); - let month = from.getMonth(); - let guard = 0; - while (out.length < effectiveCap && guard < 60) { - const day = Math.min(startDay, lastDayOfMonth(year, month)); - const d = new Date(year, month, day, sched.hour, sched.minute, 0, 0); - if (d > to) break; - if (d >= from) out.push(d); - month += Math.max(1, sched.repeat_every); - year += Math.floor(month / 12); - month = ((month % 12) + 12) % 12; - guard += 1; - } - return out; - } - - const allowed = sched.on_days; - if (allowed.length === 0) return []; - const stepWeeks = Math.max(1, sched.repeat_every); - const anchorWeek = new Date(cursor); - anchorWeek.setDate(anchorWeek.getDate() - anchorWeek.getDay()); - for (let i = 0; i < 60 && out.length < effectiveCap; i += 1) { - const day = new Date(cursor); - day.setDate(day.getDate() + i); - const candidateWeek = new Date(day); - candidateWeek.setDate(candidateWeek.getDate() - candidateWeek.getDay()); - const weekDelta = Math.floor((candidateWeek.getTime() - anchorWeek.getTime()) / (7 * 86400000)); - if (!allowed.includes(day.getDay())) continue; - if (weekDelta !== 0 && weekDelta % stepWeeks !== 0) continue; - day.setHours(sched.hour, sched.minute, 0, 0); - if (day >= from && day <= to) out.push(day); - if (day > to) break; - } - return out; -}