/schedules lists the controller's scheduled prompts as cards: name as the title, the agents it fires for, the message, its cadence and next fire. Edit turns a card into its own form; pause, resume and remove act in place, and + schedule opens an empty form. The form sends only what changed, so a rename never moves the fire times, and warns when the start or the repeat does. Filters reuse the shared card filter bar. Adds a TextAreaField to the ui kit, and an upcoming mode on RelativeTime so a next-fire time reads "in 5m". Needs the controller's /api/schedules routes.
294 lines
9.7 KiB
TypeScript
294 lines
9.7 KiB
TypeScript
// <ScheduleForm> — create or edit one scheduled prompt, rendered as a card
|
|
// so an edited schedule's card turns into its own form in place (mara: "the
|
|
// edit button changes the card to be the edit form"). With `schedule` it
|
|
// edits and sends a PATCH carrying only what changed; without, it POSTs a
|
|
// new one.
|
|
//
|
|
// Cadence is either "once" or "every N unit", plus a start time: the fire
|
|
// time of a one-shot, the origin of the fire grid of a repeating schedule.
|
|
// Changing the start or the interval moves that grid; the name, message and
|
|
// agents never do, so the form says which of the two an edit is.
|
|
import { useState } from "preact/hooks";
|
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
|
import { Button } from "../../ui/button/Button.js";
|
|
import { Card } from "../../ui/card/Card.js";
|
|
import {
|
|
MultiselectFilterOptions,
|
|
type MultiselectFilterState,
|
|
} from "../../ui/multiselect-filter/MultiselectFilter.js";
|
|
import { SelectField } from "../../ui/select-field/SelectField.js";
|
|
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
|
import { TextField } from "../../ui/text-field/TextField.js";
|
|
import {
|
|
UNIT_SECONDS,
|
|
fmtLocal,
|
|
fromLocalInput,
|
|
splitInterval,
|
|
toLocalInput,
|
|
upcomingFires,
|
|
type IntervalUnit,
|
|
type Schedule,
|
|
} from "./ScheduleTypes.js";
|
|
import "./ScheduleForm.css";
|
|
|
|
const UNIT_OPTIONS = [
|
|
{ value: "s", label: "seconds" },
|
|
{ value: "m", label: "minutes" },
|
|
{ value: "h", label: "hours" },
|
|
{ value: "d", label: "days" },
|
|
];
|
|
|
|
const PREVIEW_COUNT = 3;
|
|
|
|
// The current time rounded up to the next whole minute: what a new schedule
|
|
// starts at unless the operator picks another time. Rounded because the
|
|
// time input has minute resolution.
|
|
function nextMinuteUnix(): number {
|
|
return Math.ceil(Date.now() / 60_000) * 60;
|
|
}
|
|
|
|
export function ScheduleForm({
|
|
schedule,
|
|
agentOptions,
|
|
onSaved,
|
|
onCancel,
|
|
}: {
|
|
/** The schedule being edited; absent for a new one. */
|
|
schedule?: Schedule;
|
|
/** Every agent a schedule can target. */
|
|
agentOptions: string[];
|
|
onSaved: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const initialInterval =
|
|
schedule?.interval_seconds != null
|
|
? splitInterval(schedule.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<string[]>(schedule?.agents ?? []);
|
|
const [repeat, setRepeat] = useState(
|
|
schedule ? schedule.interval_seconds !== null : true,
|
|
);
|
|
const [amount, setAmount] = useState(String(initialInterval.amount));
|
|
const [unit, setUnit] = useState<IntervalUnit>(initialInterval.unit);
|
|
const [start, setStart] = useState(initialStart);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
|
|
|
const idPrefix = `schedule-${schedule?.id ?? "new"}`;
|
|
const amountNum = Number(amount);
|
|
const intervalSeconds =
|
|
repeat && Number.isInteger(amountNum) && amountNum > 0
|
|
? amountNum * UNIT_SECONDS[unit]
|
|
: null;
|
|
const startUnix = fromLocalInput(start);
|
|
const cadenceValid = startUnix !== null && (!repeat || intervalSeconds !== null);
|
|
|
|
const picked: MultiselectFilterState = { values: agents, negate: false };
|
|
const toggleAgent = (a: string) =>
|
|
setAgents((cur) =>
|
|
cur.includes(a) ? cur.filter((x) => x !== a) : [...cur, a],
|
|
);
|
|
|
|
// The grid moves when the start or the repeat interval differs from what
|
|
// the schedule has now; only meaningful when editing. The start compares
|
|
// as the text the input shows, not as seconds: the input has minute
|
|
// resolution, so a schedule created off a whole minute would otherwise
|
|
// always look edited.
|
|
const startChanged = start !== initialStart;
|
|
const cadenceChanged =
|
|
schedule !== undefined &&
|
|
(startChanged ||
|
|
(repeat ? intervalSeconds : null) !== schedule.interval_seconds);
|
|
|
|
const fires =
|
|
cadenceValid && startUnix !== null
|
|
? upcomingFires(
|
|
startUnix,
|
|
repeat ? intervalSeconds : null,
|
|
Math.floor(Date.now() / 1000),
|
|
PREVIEW_COUNT,
|
|
)
|
|
: [];
|
|
|
|
async function submit(e: Event) {
|
|
e.preventDefault();
|
|
if (!cadenceValid || startUnix === null) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const cadence = repeat ? intervalSeconds : null;
|
|
const patch = schedule
|
|
? patchBody(schedule, {
|
|
name,
|
|
body,
|
|
agents,
|
|
startUnix: startChanged ? startUnix : null,
|
|
cadence,
|
|
})
|
|
: null;
|
|
// Nothing differs: there is nothing to send.
|
|
if (patch && Object.keys(patch).length === 0) {
|
|
onSaved();
|
|
return;
|
|
}
|
|
const r = schedule
|
|
? await fetch(`/api/schedules/${encodeURIComponent(schedule.id)}`, {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(patch),
|
|
})
|
|
: await fetch("/api/schedules", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
name,
|
|
agents,
|
|
body,
|
|
anchor_unix: startUnix,
|
|
interval_seconds: cadence,
|
|
}),
|
|
});
|
|
if (!r.ok) {
|
|
setError(await readApiError(r));
|
|
return;
|
|
}
|
|
onSaved();
|
|
} catch (err) {
|
|
setError({ detail: String(err) });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card class="ui-schedule-form-card">
|
|
<form class="ui-schedule-form" onSubmit={submit}>
|
|
<TextField
|
|
id={`${idPrefix}-name`}
|
|
label="name"
|
|
value={name}
|
|
required
|
|
onInput={setName}
|
|
/>
|
|
<div class="ui-schedule-form-agents">
|
|
<span class="ui-form-field-label">agents</span>
|
|
{agentOptions.length === 0 ? (
|
|
<span class="ui-schedule-form-hint">no agents to pick from</span>
|
|
) : (
|
|
<MultiselectFilterOptions
|
|
options={agentOptions}
|
|
filter={picked}
|
|
onToggle={toggleAgent}
|
|
/>
|
|
)}
|
|
</div>
|
|
<TextAreaField
|
|
id={`${idPrefix}-body`}
|
|
label="message"
|
|
value={body}
|
|
required
|
|
onInput={setBody}
|
|
/>
|
|
<div class="ui-schedule-form-cadence">
|
|
<SelectField
|
|
id={`${idPrefix}-repeat`}
|
|
label="repeats"
|
|
value={repeat ? "every" : "once"}
|
|
onChange={(v) => setRepeat(v === "every")}
|
|
options={[
|
|
{ value: "every", label: "every…" },
|
|
{ value: "once", label: "once" },
|
|
]}
|
|
/>
|
|
{repeat ? (
|
|
<>
|
|
<TextField
|
|
id={`${idPrefix}-amount`}
|
|
label="every"
|
|
type="number"
|
|
value={amount}
|
|
required
|
|
onInput={setAmount}
|
|
/>
|
|
<SelectField
|
|
id={`${idPrefix}-unit`}
|
|
label="unit"
|
|
value={unit}
|
|
onChange={(v) => setUnit(v as IntervalUnit)}
|
|
options={UNIT_OPTIONS}
|
|
/>
|
|
</>
|
|
) : null}
|
|
<TextField
|
|
id={`${idPrefix}-start`}
|
|
label={repeat ? "starts at" : "fires at"}
|
|
type="datetime-local"
|
|
value={start}
|
|
required
|
|
onInput={setStart}
|
|
/>
|
|
</div>
|
|
{fires.length > 0 ? (
|
|
<p class="ui-schedule-form-hint">
|
|
{repeat ? "next fires: " : "fires: "}
|
|
{fires.map(fmtLocal).join(" · ")}
|
|
</p>
|
|
) : cadenceValid ? (
|
|
<p class="ui-schedule-form-hint">that time has already passed</p>
|
|
) : null}
|
|
{cadenceChanged ? (
|
|
<p class="ui-schedule-form-warn">
|
|
Changing the start or the repeat moves when this schedule fires.
|
|
Editing only the name, message or agents does not.
|
|
</p>
|
|
) : null}
|
|
{error ? (
|
|
<ApiErrorPanel context="failed to save the schedule" problem={error} />
|
|
) : null}
|
|
<div class="ui-schedule-form-actions">
|
|
<Button onClick={onCancel}>cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
disabled={saving || !cadenceValid || agents.length === 0}
|
|
>
|
|
{saving ? "saving…" : schedule ? "save" : "create"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// A PATCH body holding only the fields that differ from `schedule`, so an
|
|
// edit never rewrites what the operator did not touch (and a body-only edit
|
|
// cannot move the fire grid by resending the anchor).
|
|
function patchBody(
|
|
schedule: Schedule,
|
|
next: {
|
|
name: string;
|
|
body: string;
|
|
agents: string[];
|
|
/** The new start, or null when the operator left it alone. */
|
|
startUnix: number | null;
|
|
cadence: number | null;
|
|
},
|
|
): Record<string, unknown> {
|
|
const patch: Record<string, unknown> = {};
|
|
if (next.name !== schedule.name) patch.name = next.name;
|
|
if (next.body !== schedule.body) patch.body = next.body;
|
|
if (next.startUnix !== null) patch.anchor_unix = next.startUnix;
|
|
if (next.cadence !== schedule.interval_seconds) {
|
|
patch.interval_seconds = next.cadence;
|
|
}
|
|
const add = next.agents.filter((a) => !schedule.agents.includes(a));
|
|
const remove = schedule.agents.filter((a) => !next.agents.includes(a));
|
|
if (add.length > 0) patch.add_agents = add;
|
|
if (remove.length > 0) patch.remove_agents = remove;
|
|
return patch;
|
|
}
|