// 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 moved to the in-container store (reminders // migration) and are no longer listed here — they surface via // get_loose_ends / the todos pill on the agent page instead. 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 `