mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[aidan] feat/calendar: render fire times from backend, drop JS recurrence reimpl
This commit is contained in:
@@ -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<Occ[]>(() => {
|
||||
// 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<Occ[]>(() => {
|
||||
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<string, Occ[]>();
|
||||
|
||||
@@ -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<ComingGroup[]>(() => {
|
||||
const from = new Date(now); from.setHours(0, 0, 0, 0);
|
||||
const to = new Date(from.getTime() + 7 * 86400000);
|
||||
const byDay = new Map<string, ComingRun[]>();
|
||||
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,
|
||||
|
||||
@@ -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 }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && needsScheduleTestWarning(workflow) && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 12, padding: '9px 11px', background: `${WC.warn}14`, border: `1px solid ${WC.warn}40`, borderRadius: 8 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="1.9" style={{ flex: 'none', marginTop: 1 }}><path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></svg>
|
||||
<span style={{ fontSize: 12, color: WC.ink3, lineHeight: 1.45 }}>Not test-run yet. A scheduled run can't pause for permission prompts, so if this needs tool access it may fail silently. Hit <b>Run</b> once to grant access.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', background: WC.inset, border: `1px solid ${WC.line}`, borderRadius: 9, padding: 3, gap: 2, marginBottom: 12 }}>
|
||||
{FREQS.map(([k, label]) => (
|
||||
<button key={k} onClick={() => patchSched(patchForFreq(sched, k))} style={freqBtn(freq === k)}>{label}</button>
|
||||
@@ -189,7 +196,7 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
|
||||
</div>
|
||||
<div style={{ marginTop: 7, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 14, display: 'flex', justifyContent: 'center', flex: 'none' }}><div style={{ width: 6, height: 6, borderRadius: '50%', background: WC.accent }} /></div>
|
||||
<span style={{ fontSize: 12.5, color: WC.ink4 }}>Next run {nextRunText(workflow)}</span>
|
||||
<span style={{ fontSize: 12.5, color: WC.ink4 }}>Next run {nextRunText(workflow, workflow.next_run_at ? new Date(workflow.next_run_at) : null)}</span>
|
||||
</div>
|
||||
{maxRuns != null && (
|
||||
<div style={{ marginTop: 7, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CalendarOccurrence[]>([]);
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user