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:
parent
4fdc061c9d
commit
caa0c6ce4c
3 changed files with 168 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue