// Dashboard SCH3DUL3S tab — scheduled-prompts.
//
// The operator's scheduled prompts: the list, the inline create row,
// the per-row edit form, and the fire-now / cancel actions. Live updates
// arrive via the `schedules_changed` dashboard event (wired into the
// entry's mutation dispatch table); the tab-activation re-fetch is the
// cold-load / reconnect recovery path.
//
// Owns its own module state (the schedules list + edit-in-progress
// tracking); reads the shared agent roster from state.js for the
// target-chip pickers. Render + format helpers come from util.js.
//
// Note: per-agent reminders live in the in-container store, not here —
// they surface via get_loose_ends / the todos pill on the agent page.
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import { paintAtomic, epochSec, fmtAgo, fmtDuration } from "./util.js";
import { containersState } from "./state.js";
import { asyncBtn } from "@hive/shared/forms.js";
// ─── scheduled prompts ─────────────────────────────────────────────────
// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
// (whole or per-target), `/api/schedules/{id}` (PATCH edit),
// `/api/schedules/{id}/fire-now` (POST). Mutations now emit a
// `schedules_changed` SSE event so the list updates live;
// `applySchedulesChanged` handles it. Tab-activation re-fetch kept as
// a safety net for approval-path inserts and disconnect windows.
// Local cache lets `refreshTabCounts` show the active count without
// re-fetching every second.
let schedulesState = [];
// Schedule ids whose inline edit form is currently open. The set
// survives across refreshSchedules() calls so a state
// poll doesn't yank the form out from under the operator. Per-id
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
// also rides the refresh.
const editingSchedules = new Set();
const scheduleEditCarry = new Map();
export async function refreshSchedules() {
const listRoot = $("schedules-section");
if (!listRoot) return;
try {
const resp = await fetch("/api/schedules");
if (!resp.ok) {
paintAtomic(listRoot, (root) => {
root.append(
el(
"p",
{ class: "empty" },
"schedules unavailable: http " + resp.status,
),
);
});
return;
}
schedulesState = await resp.json();
} catch (err) {
paintAtomic(listRoot, (root) => {
root.append(
el("p", { class: "empty" }, "schedules fetch failed: " + err),
);
});
return;
}
renderSchedulesList();
}
// Active = at least one target still alive (no `cancelled_at_unix`)
// AND the whole schedule isn't cancelled. Drives the tab pill count.
export function activeScheduleCount() {
let n = 0;
for (const s of schedulesState) {
if (s.cancelled_at_unix) continue;
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
}
return n;
}
// Label + caption wrapper shared by every field on the schedule
// forms — ``. Variadic
// children land inside the label after the caption span, which is
// what every caller wants (the input + any preset/parts/preview
// sub-rows live next to the caption).
function scheduleField(labelText, ...children) {
return el(
"label",
{ class: "schedule-field" },
el("span", { class: "schedule-field-label" }, labelText),
...children,
);
}
// Interval composer — shared between the new-schedule form and
// the edit-schedule form. Builds the preset chip row +
// d/h/m/s sub-fields + live preview, and returns helpers to read
// and write the value.
//
// The composer carries no opinion on what "0 / blank" means at the
// semantic layer: it just reports the integer total. Callers decide
// whether to treat 0 as `null` (one-shot) for their own submit path.
function buildIntervalComposer({
label = "interval (blank / all-zero = one-shot)",
namePrefix = "interval_",
initialSeconds = 0,
} = {}) {
const wrapper = scheduleField(label);
const presets = [
["1m", 60],
["5m", 300],
["15m", 900],
["30m", 1800],
["1h", 3600],
["6h", 21600],
["12h", 43200],
["1d", 86400],
["7d", 604800],
];
const presetsRow = el("div", { class: "schedule-interval-presets" });
for (const [presetLabel, secs] of presets) {
presetsRow.append(
el(
"button",
{
type: "button",
class: "btn btn-interval-preset",
"data-secs": String(secs),
},
presetLabel,
),
);
}
presetsRow.append(
el(
"button",
{
type: "button",
class: "btn btn-interval-preset btn-interval-oneshot",
"data-secs": "0",
},
"one-shot",
),
);
wrapper.append(presetsRow);
const partsRow = el("div", { class: "schedule-interval-parts" });
const mkPart = (suffix, unit) => {
const inp = el("input", {
type: "number",
name: namePrefix + suffix,
min: "0",
step: "1",
placeholder: "0",
class: "schedule-interval-num",
});
partsRow.append(inp, el("span", { class: "schedule-interval-unit" }, unit));
return inp;
};
const dInp = mkPart("d", "d");
const hInp = mkPart("h", "h");
const mInp = mkPart("m", "m");
const sInp = mkPart("s", "s");
wrapper.append(partsRow);
const preview = el("div", {
class: "schedule-interval-preview",
"aria-live": "polite",
});
wrapper.append(preview);
function getSeconds() {
const n = (v) => {
const x = parseInt(v, 10);
return Number.isFinite(x) && x > 0 ? x : 0;
};
return (
n(dInp.value) * 86400 +
n(hInp.value) * 3600 +
n(mInp.value) * 60 +
n(sInp.value)
);
}
function fillFromSeconds(total) {
const t = Math.max(0, Math.floor(total));
const d = Math.floor(t / 86400);
const h = Math.floor((t % 86400) / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
dInp.value = d > 0 ? String(d) : "";
hInp.value = h > 0 ? String(h) : "";
mInp.value = m > 0 ? String(m) : "";
sInp.value = s > 0 ? String(s) : "";
}
function setParts({ d, h, m, s }) {
if (d !== undefined) dInp.value = d;
if (h !== undefined) hInp.value = h;
if (m !== undefined) mInp.value = m;
if (s !== undefined) sInp.value = s;
}
function updatePreview() {
const total = getSeconds();
preview.textContent =
total > 0
? "↻ every " + fmtDuration(total)
: "one-shot (fires once at first-fire time)";
preview.classList.toggle("schedule-interval-preview-oneshot", total === 0);
}
for (const inp of [dInp, hInp, mInp, sInp]) {
inp.addEventListener("input", updatePreview);
}
for (const b of presetsRow.querySelectorAll("button[data-secs]")) {
b.addEventListener("click", () => {
fillFromSeconds(parseInt(b.getAttribute("data-secs"), 10) || 0);
updatePreview();
});
}
if (initialSeconds > 0) fillFromSeconds(initialSeconds);
updatePreview();
return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
}
// Parse the d/h/m/s sub-fields of a `buildIntervalComposer` instance
// out of submitted FormData. Returns total seconds, or NaN if any
// field carries a non-integer / negative value — the caller alerts
// and bails. Total `0` means "one-shot" at the call site (wire is
// `interval_seconds: null`). `namePrefix` matches the prefix passed
// to `buildIntervalComposer` (e.g. `interval_` or `edit_interval_`).
function intervalSecondsFromFormData(fd, namePrefix) {
const part = (suffix, mult) => {
const raw = String(fd.get(namePrefix + suffix) || "").trim();
if (!raw) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return NaN;
return n * mult;
};
return part("d", 86400) + part("h", 3600) + part("m", 60) + part("s", 1);
}
// Targets multi-select chip box — shared between the new-schedule
// form and the edit-schedule form. Same DOM shape, same candidate
// list (containers + operator + root); only the chip element id
// prefix and checkbox field name vary. Used to be inlined twice in
// near-identical 18-line blocks; consolidated here so a future
// change (new chip kind, candidate-list source swap, etc.) lives in
// one place. Returns the wrapping `