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
|
targets (multi-select checkboxes drawn from live container
|
||||||
names + `operator` + `manager`), prompt body (textarea),
|
names + `operator` + `manager`), prompt body (textarea),
|
||||||
first-fire datetime-local (pre-filled to 5 minutes from now),
|
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
|
and an optional human-readable description. On submit the
|
||||||
form POSTs to `/api/schedules` as JSON; the tab pill shows
|
form POSTs to `/api/schedules` as JSON; the tab pill shows
|
||||||
the count of active schedules (at least one live target not
|
the count of active schedules (at least one live target not
|
||||||
|
|
|
||||||
|
|
@ -2019,15 +2019,105 @@ window.marked = marked;
|
||||||
firstFireLabel.append(firstFireInput);
|
firstFireLabel.append(firstFireInput);
|
||||||
form_.append(firstFireLabel);
|
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' },
|
const intervalLabel = el('label', { class: 'schedule-field' },
|
||||||
el('span', { class: 'schedule-field-label' }, 'interval (seconds; blank = one-shot)'));
|
el('span', { class: 'schedule-field-label' }, 'interval (blank / all-zero = one-shot)'));
|
||||||
const intervalInput = el('input', {
|
const presets = [
|
||||||
type: 'number', name: 'interval', min: '1', step: '1', placeholder: 'e.g. 3600 for hourly',
|
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
|
||||||
});
|
['1h', 3600], ['6h', 21600], ['12h', 43200],
|
||||||
intervalInput.value = carry.interval;
|
['1d', 86400], ['7d', 604800],
|
||||||
intervalLabel.append(intervalInput);
|
];
|
||||||
|
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);
|
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' },
|
const descLabel = el('label', { class: 'schedule-field' },
|
||||||
el('span', { class: 'schedule-field-label' }, 'description (optional)'));
|
el('span', { class: 'schedule-field-label' }, 'description (optional)'));
|
||||||
const descInput = el('input', {
|
const descInput = el('input', {
|
||||||
|
|
@ -2049,7 +2139,13 @@ window.marked = marked;
|
||||||
targets: Array.from(root.querySelectorAll('input[name="targets"]:checked')).map((i) => i.value),
|
targets: Array.from(root.querySelectorAll('input[name="targets"]:checked')).map((i) => i.value),
|
||||||
body: root.querySelector('textarea[name="body"]')?.value || '',
|
body: root.querySelector('textarea[name="body"]')?.value || '',
|
||||||
first_fire: root.querySelector('input[name="first_fire"]')?.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: 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 || '',
|
description: root.querySelector('input[name="description"]')?.value || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -2066,7 +2162,6 @@ window.marked = marked;
|
||||||
const targets = fd.getAll('targets').map(String);
|
const targets = fd.getAll('targets').map(String);
|
||||||
const body = String(fd.get('body') || '').trim();
|
const body = String(fd.get('body') || '').trim();
|
||||||
const firstFireStr = String(fd.get('first_fire') || '');
|
const firstFireStr = String(fd.get('first_fire') || '');
|
||||||
const intervalStr = String(fd.get('interval') || '').trim();
|
|
||||||
const description = String(fd.get('description') || '').trim();
|
const description = String(fd.get('description') || '').trim();
|
||||||
|
|
||||||
if (!targets.length) { alert('schedule must have at least one target'); return; }
|
if (!targets.length) { alert('schedule must have at least one target'); return; }
|
||||||
|
|
@ -2075,15 +2170,26 @@ window.marked = marked;
|
||||||
const firstFireDate = new Date(firstFireStr);
|
const firstFireDate = new Date(firstFireStr);
|
||||||
if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; }
|
if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; }
|
||||||
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
||||||
let interval_seconds = null;
|
// Interval composer (#466): combine d/h/m/s into total seconds.
|
||||||
if (intervalStr) {
|
// Negative values are rejected; total == 0 means one-shot, same
|
||||||
const parsed = parseInt(intervalStr, 10);
|
// semantic the backend expects when interval_seconds is null.
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
const partSecs = (name, mult) => {
|
||||||
alert('interval must be a positive integer (or blank for one-shot)');
|
const raw = String(fd.get(name) || '').trim();
|
||||||
return;
|
if (!raw) return 0;
|
||||||
}
|
const n = parseInt(raw, 10);
|
||||||
interval_seconds = parsed;
|
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 };
|
const payload = { targets, body, first_fire_at_unix };
|
||||||
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
|
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
|
||||||
if (description) payload.description = description;
|
if (description) payload.description = description;
|
||||||
|
|
|
||||||
|
|
@ -1665,6 +1665,50 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
||||||
justify-content: flex-end;
|
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 {
|
.schedules {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue