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(" · ")}
+ 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";
}