From caa0c6ce4c4c3ddf3c55806da07347e047991a1a Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 13:34:28 +0200 Subject: [PATCH] dashboard: friendlier interval input for scheduled prompts (closes #466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single raw-seconds field in the SCH3DUL3D PR0MPTS creation form with a structured composer: - Preset chip row (1m / 5m / 15m / 30m / 1h / 6h / 12h / 1d / 7d / one-shot) that fills the parts inputs in one click - Four small d/h/m/s number inputs combined into total seconds on submit (all-zero = one-shot, preserving the backend null semantic) - Live "↻ every …" preview using the existing fmtDuration helper so the operator sees what they're about to queue Carry semantics extended to round-trip the split fields across state-poll re-renders; legacy `interval` carry still honoured if an older bundle is in the page. Docs (web-ui.md) updated to describe the new composer. --- docs/web-ui.md | 4 +- frontend/packages/dashboard/src/app.js | 136 ++++++++++++++++-- frontend/packages/dashboard/src/dashboard.css | 44 ++++++ 3 files changed, 168 insertions(+), 16 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index 77e7bfde..957de0ff 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -234,7 +234,9 @@ creation form lets the operator queue a new schedule directly: targets (multi-select checkboxes drawn from live container names + `operator` + `manager`), prompt body (textarea), first-fire datetime-local (pre-filled to 5 minutes from now), -optional recurrence interval in seconds (blank = one-shot), +an interval composer (#466 — preset chips for common +durations + separate d/h/m/s number fields with a live +"↻ every …" preview; all-zero = one-shot), and an optional human-readable description. On submit the form POSTs to `/api/schedules` as JSON; the tab pill shows the count of active schedules (at least one live target not diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index b12d7a14..cd1ed70a 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -2019,15 +2019,105 @@ window.marked = marked; firstFireLabel.append(firstFireInput); form_.append(firstFireLabel); + // Interval composer (#466). Replaces the raw seconds input with a + // preset chip row + d/h/m/s sub-fields + a live preview, so the + // operator never has to multiply hours-to-seconds by hand. Total + // of zero (all fields blank or 0) means one-shot — same semantic + // the backend already expects when interval_seconds is null. const intervalLabel = el('label', { class: 'schedule-field' }, - el('span', { class: 'schedule-field-label' }, 'interval (seconds; blank = one-shot)')); - const intervalInput = el('input', { - type: 'number', name: 'interval', min: '1', step: '1', placeholder: 'e.g. 3600 for hourly', - }); - intervalInput.value = carry.interval; - intervalLabel.append(intervalInput); + el('span', { class: 'schedule-field-label' }, 'interval (blank / all-zero = one-shot)')); + 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 [label, secs] of presets) { + const b = el('button', { + type: 'button', + class: 'btn btn-interval-preset', + 'data-secs': String(secs), + }, label); + presetsRow.append(b); + } + const oneShotBtn = el('button', { + type: 'button', + class: 'btn btn-interval-preset btn-interval-oneshot', + 'data-secs': '0', + }, 'one-shot'); + presetsRow.append(oneShotBtn); + intervalLabel.append(presetsRow); + + const partsRow = el('div', { class: 'schedule-interval-parts' }); + const mkPart = (name, unit) => { + const inp = el('input', { + type: 'number', name, 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('interval_d', 'd'); + const hInp = mkPart('interval_h', 'h'); + const mInp = mkPart('interval_m', 'm'); + const sInp = mkPart('interval_s', 's'); + intervalLabel.append(partsRow); + + const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' }); + intervalLabel.append(preview); form_.append(intervalLabel); + function readPartsSecs() { + 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 fillParts(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 updatePreview() { + const total = readPartsSecs(); + 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', () => { + fillParts(parseInt(b.getAttribute('data-secs'), 10) || 0); + updatePreview(); + }); + } + + // Restore from carry. Prefer the new split fields; fall back to + // legacy single seconds value if the page was reloaded mid-edit + // against an older bundle. + if (carry.interval_d || carry.interval_h + || carry.interval_m || carry.interval_s) { + dInp.value = carry.interval_d; + hInp.value = carry.interval_h; + mInp.value = carry.interval_m; + sInp.value = carry.interval_s; + } else if (carry.interval) { + const legacy = parseInt(carry.interval, 10); + if (Number.isFinite(legacy) && legacy > 0) fillParts(legacy); + } + updatePreview(); + const descLabel = el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, 'description (optional)')); const descInput = el('input', { @@ -2049,7 +2139,13 @@ window.marked = marked; targets: Array.from(root.querySelectorAll('input[name="targets"]:checked')).map((i) => i.value), body: root.querySelector('textarea[name="body"]')?.value || '', first_fire: root.querySelector('input[name="first_fire"]')?.value || '', + // #466: interval is now split into d/h/m/s sub-fields. Keep the + // legacy `interval` key in case an older bundle is in the page. interval: root.querySelector('input[name="interval"]')?.value || '', + interval_d: root.querySelector('input[name="interval_d"]')?.value || '', + interval_h: root.querySelector('input[name="interval_h"]')?.value || '', + interval_m: root.querySelector('input[name="interval_m"]')?.value || '', + interval_s: root.querySelector('input[name="interval_s"]')?.value || '', description: root.querySelector('input[name="description"]')?.value || '', }; } @@ -2066,7 +2162,6 @@ window.marked = marked; const targets = fd.getAll('targets').map(String); const body = String(fd.get('body') || '').trim(); const firstFireStr = String(fd.get('first_fire') || ''); - const intervalStr = String(fd.get('interval') || '').trim(); const description = String(fd.get('description') || '').trim(); if (!targets.length) { alert('schedule must have at least one target'); return; } @@ -2075,15 +2170,26 @@ window.marked = marked; const firstFireDate = new Date(firstFireStr); if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; } const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000); - let interval_seconds = null; - if (intervalStr) { - const parsed = parseInt(intervalStr, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - alert('interval must be a positive integer (or blank for one-shot)'); - return; - } - interval_seconds = parsed; + // Interval composer (#466): combine d/h/m/s into total seconds. + // Negative values are rejected; total == 0 means one-shot, same + // semantic the backend expects when interval_seconds is null. + const partSecs = (name, mult) => { + const raw = String(fd.get(name) || '').trim(); + if (!raw) return 0; + const n = parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0) return NaN; + return n * mult; + }; + const partD = partSecs('interval_d', 86400); + const partH = partSecs('interval_h', 3600); + const partM = partSecs('interval_m', 60); + const partS = partSecs('interval_s', 1); + if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) { + alert('interval fields must be non-negative integers (or blank for one-shot)'); + return; } + const intervalTotal = partD + partH + partM + partS; + const interval_seconds = intervalTotal > 0 ? intervalTotal : null; const payload = { targets, body, first_fire_at_unix }; if (interval_seconds != null) payload.interval_seconds = interval_seconds; if (description) payload.description = description; diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 1016ad0f..7a0fadfc 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -1665,6 +1665,50 @@ body.flow-shell .tabbar .tab.active.tab-link { justify-content: flex-end; } +/* Interval composer (#466). Preset chip row + d/h/m/s inputs + live + preview, so the operator never has to multiply seconds by hand. */ +.schedule-interval-presets { + display: flex; + flex-wrap: wrap; + gap: 0.3em; + margin-top: 0.1em; +} +.btn-interval-preset { + color: var(--cyan); + border-color: var(--cyan); + font-size: 0.75em; + padding: 0.15em 0.55em; + letter-spacing: 0.05em; +} +.btn-interval-oneshot { + color: var(--muted); + border-color: var(--muted); +} +.schedule-interval-parts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35em; + margin-top: 0.4em; +} +.schedule-interval-num { + width: 4em; + text-align: right; + font-variant-numeric: tabular-nums; +} +.schedule-interval-unit { + color: var(--muted); + font-size: 0.85em; + margin-right: 0.2em; +} +.schedule-interval-preview { + margin-top: 0.4em; + color: var(--cyan); + font-size: 0.9em; + font-variant-numeric: tabular-nums; +} +.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; } + .schedules { display: flex; flex-direction: column;