// — 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(schedule?.agents ?? []); const [repeat, setRepeat] = useState( schedule ? schedule.interval_seconds !== null : true, ); const [amount, setAmount] = useState(String(initialInterval.amount)); const [unit, setUnit] = useState(initialInterval.unit); const [start, setStart] = useState(initialStart); const [saving, setSaving] = useState(false); const [error, setError] = useState(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 (
agents {agentOptions.length === 0 ? ( no agents to pick from ) : ( )}
setRepeat(v === "every")} options={[ { value: "every", label: "every…" }, { value: "once", label: "once" }, ]} /> {repeat ? ( <> setUnit(v as IntervalUnit)} options={UNIT_OPTIONS} /> ) : null}
{fires.length > 0 ? (

{repeat ? "next fires: " : "fires: "} {fires.map(fmtLocal).join(" · ")}

) : cadenceValid ? (

that time has already passed

) : null} {cadenceChanged ? (

Changing the start or the repeat moves when this schedule fires. Editing only the name, message or agents does not.

) : null} {error ? ( ) : null}
); } // 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 { const patch: Record = {}; 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; }