From 0af7f53e887ea90633deb797f9c2f7a8e1190cdd Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 4 Oct 2026 17:07:29 +0200 Subject: [PATCH] swarm-ui: schedules page says what a past start does, no longer claims a one-shot fired A start in the past fires the latest missed time once, right away, so the form says that instead of 'already passed'. A one-shot past its time reads 'past' / 'time passed' (the controller's clock, not a record of firing) and keeps its pause button. The edit form compares against what it opened with, so a schedule changing under it is not reverted on save. --- docs/swarm/ui.md | 7 ++- .../src/pages/schedules/ScheduleCard.tsx | 12 ++--- .../src/pages/schedules/ScheduleForm.tsx | 48 ++++++++++++------- .../src/pages/schedules/ScheduleTypes.ts | 9 ++-- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index aa217993..b88de031 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -40,12 +40,15 @@ title, with the **agents** it fires for, the message, how often it repeats and when it fires next. **edit** turns the card into its own form; **pause** and **resume** stop and restart it; **remove** deletes it after a confirmation. **+ schedule** opens an empty form above the list, and the filters narrow the -list by name, agent or state (`active`, `paused`, or `done` for a one-shot that -has fired). +list by name, agent or state (`active`, `paused`, or `past` for a one-shot +whose time has passed). `past` is the controller's clock against the start time, +not a record that an agent fired, so a `past` schedule can still be paused. A schedule repeats **every** some number of seconds, minutes, hours or days, or fires **once**. Its start time is the moment a one-shot fires, and the origin of the fire times of a repeating one. The form previews the next three fire times. +A start in the past fires the latest missed time once, right away, in every +agent chosen, and the form says so. Changing the start or the repeat moves them; editing only the name, message or agents does not, and the form warns when an edit would. diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx index 00748032..8c604282 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx @@ -18,7 +18,7 @@ import "./ScheduleCard.css"; const STATE_TONE: Record = { active: "positive", paused: "warning", - done: "neutral", + past: "neutral", }; export function ScheduleCard({ @@ -65,7 +65,7 @@ export function ScheduleCard({ "paused, will not fire" ) : ( <> - fired{" "} + time passed{" "} @@ -76,11 +76,9 @@ export function ScheduleCard({ - {state !== "done" ? ( - - ) : null} + diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx index e96c5279..5da69cca 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx @@ -61,17 +61,23 @@ export function ScheduleForm({ onSaved: () => void; onCancel: () => void; }) { + // What the form opened with. A refetch can change `schedule` under an open + // form; every "did the operator edit this" comparison is against this + // snapshot, so another edit landing meanwhile is not reverted on save. + const [original] = useState(schedule); + const [initialStart] = useState(() => + toLocalInput(original?.anchor_unix ?? nextMinuteUnix()), + ); const initialInterval = - schedule?.interval_seconds != null - ? splitInterval(schedule.interval_seconds) + original?.interval_seconds != null + ? splitInterval(original.interval_seconds) : { amount: 1, unit: "h" as IntervalUnit }; - const initialStart = toLocalInput(schedule?.anchor_unix ?? nextMinuteUnix()); - const [name, setName] = useState(schedule?.name ?? ""); - const [body, setBody] = useState(schedule?.body ?? ""); - const [agents, setAgents] = useState(schedule?.agents ?? []); + const [name, setName] = useState(original?.name ?? ""); + const [body, setBody] = useState(original?.body ?? ""); + const [agents, setAgents] = useState(original?.agents ?? []); const [repeat, setRepeat] = useState( - schedule ? schedule.interval_seconds !== null : true, + original ? original.interval_seconds !== null : true, ); const [amount, setAmount] = useState(String(initialInterval.amount)); const [unit, setUnit] = useState(initialInterval.unit); @@ -79,7 +85,7 @@ export function ScheduleForm({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const idPrefix = `schedule-${schedule?.id ?? "new"}`; + const idPrefix = `schedule-${original?.id ?? "new"}`; const amountNum = Number(amount); const intervalSeconds = repeat && Number.isInteger(amountNum) && amountNum > 0 @@ -101,16 +107,18 @@ export function ScheduleForm({ // always look edited. const startChanged = start !== initialStart; const cadenceChanged = - schedule !== undefined && + original !== undefined && (startChanged || - (repeat ? intervalSeconds : null) !== schedule.interval_seconds); + (repeat ? intervalSeconds : null) !== original.interval_seconds); + const nowUnix = Math.floor(Date.now() / 1000); + const startInPast = startUnix !== null && startUnix <= nowUnix; const fires = cadenceValid && startUnix !== null ? upcomingFires( startUnix, repeat ? intervalSeconds : null, - Math.floor(Date.now() / 1000), + nowUnix, PREVIEW_COUNT, ) : []; @@ -122,8 +130,8 @@ export function ScheduleForm({ setError(null); try { const cadence = repeat ? intervalSeconds : null; - const patch = schedule - ? patchBody(schedule, { + const patch = original + ? patchBody(original, { name, body, agents, @@ -136,8 +144,8 @@ export function ScheduleForm({ onSaved(); return; } - const r = schedule - ? await fetch(`/api/schedules/${encodeURIComponent(schedule.id)}`, { + const r = original + ? await fetch(`/api/schedules/${encodeURIComponent(original.id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch), @@ -238,8 +246,12 @@ export function ScheduleForm({ {repeat ? "next fires: " : "fires: "} {fires.map(fmtLocal).join(" · ")}

- ) : cadenceValid ? ( -

that time has already passed

+ ) : null} + {startInPast && (original === undefined || startChanged) ? ( +

+ That start is in the past: saving fires the latest missed time once, + right away, in every agent chosen. +

) : null} {cadenceChanged ? (

@@ -257,7 +269,7 @@ export function ScheduleForm({ type="submit" disabled={saving || !cadenceValid || agents.length === 0} > - {saving ? "saving…" : schedule ? "save" : "create"} + {saving ? "saving…" : original ? "save" : "create"} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleTypes.ts b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleTypes.ts index d04eed50..f1c9d2e8 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleTypes.ts @@ -16,13 +16,14 @@ export interface Schedule { next_fire_unix: number | null; } -export type ScheduleState = "active" | "paused" | "done"; +export type ScheduleState = "active" | "paused" | "past"; -// A one-shot that has fired reads "done" rather than "active": nothing is -// left to wait for, but the card stays until the operator removes it. +// A one-shot whose time has passed reads "past" rather than "active": the +// controller has no next fire for it. That is only its clock against the +// anchor, not a record that an agent fired, so the page never says "fired". export function scheduleState(s: Schedule): ScheduleState { if (s.paused) return "paused"; - if (s.interval_seconds === null && s.next_fire_unix === null) return "done"; + if (s.interval_seconds === null && s.next_fire_unix === null) return "past"; return "active"; }