feat(dashboard): live countdown ticker for reminder + schedule due-at labels

Reminder due-at labels ("in 3m 45s") and schedule next-fire cells
were computed once at render time and never updated. For short-horizon
reminders the stale count was visually misleading.

Both renderers now stamp data-due-at=<unix> on their respective
elements (.reminder-due / .sched-due). A shared 1s setInterval
ticker walks all live elements and rewrites textContent using
fmtDuration / fmtAgo — same zero-re-render pattern as the
existing question TTL chip ticker.
This commit is contained in:
iris 2026-06-05 11:43:33 +02:00
commit c0d7ec6572

View file

@ -455,8 +455,7 @@ window.marked = marked;
menuItem('↻ R3BU1LD', { action: '/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load; the stored localStorage
// selection is overridden so the operator lands directly in this
// The ?agent= param is read by logs.js on load and pre-selects this
// agent's journal without extra clicks.
menuLink('journal logs →',
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
@ -1999,6 +1998,11 @@ window.marked = marked;
// a request pending for an hour could still show "0s ago" without
// this ticker. Also flips `.stale` (amber highlight) at exactly 1h
// rather than only at the next re-render.
// Live countdown for reminder due-at labels and schedule next-fire
// cells. Both renderers stamp `data-due-at` on the element so this
// single ticker keeps them fresh without triggering a full re-render.
// `.reminder-due` → "overdue X ago" / "in Y"
// `.sched-due` → same pattern
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => {
@ -2008,6 +2012,14 @@ window.marked = marked;
node.textContent = 'requested ' + fmtAgo(requestedAt);
node.classList.toggle('stale', ageSec >= 3600);
});
document.querySelectorAll('.reminder-due[data-due-at], .sched-due[data-due-at]').forEach((node) => {
const dueAt = Number(node.getAttribute('data-due-at'));
if (!Number.isFinite(dueAt)) return;
const dueIn = dueAt - now;
node.textContent = dueIn <= 0
? 'overdue ' + fmtAgo(dueAt)
: (node.classList.contains('reminder-due') ? 'in ' : '') + fmtDuration(dueIn);
});
}, 1000);
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
@ -2790,7 +2802,11 @@ window.marked = marked;
: `in ${fmtDuration(dueIn)}`;
const head = el('div', { class: 'reminder-head' },
el('span', { class: 'agent' }, r.agent), ' ',
el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
el('span', {
class: 'meta reminder-due',
title: new Date(r.due_at * 1000).toISOString(),
'data-due-at': String(r.due_at),
}, dueLabel),
' ',
el('span', { class: 'meta' }, `· id ${r.id}`),
);
@ -3393,6 +3409,8 @@ window.marked = marked;
nextCell.textContent = dueIn <= 0
? 'overdue ' + fmtAgo(s.next_fire_at_unix)
: fmtDuration(dueIn);
nextCell.classList.add('sched-due');
nextCell.dataset.dueAt = String(s.next_fire_at_unix);
}
tr.append(nextCell);