dashboard: friendlier interval input for scheduled prompts (closes #466)

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.
This commit is contained in:
iris 2026-05-26 13:34:28 +02:00 committed by Mara
commit caa0c6ce4c
3 changed files with 168 additions and 16 deletions

View file

@ -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

View file

@ -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;

View file

@ -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;