diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js
index 2440c6df..a332d20e 100644
--- a/frontend/packages/dashboard/src/call.js
+++ b/frontend/packages/dashboard/src/call.js
@@ -16,7 +16,7 @@
import { $, el, form, Panel, appendLinkified } from './common.js';
import { themedToast } from './modal.js';
-import { fmtAgo, fmtDuration } from './util.js';
+import { epochSec, fmtAgo, fmtDuration } from './util.js';
import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';
// Registered by the dashboard entry at boot; defaults to a no-op so the
@@ -70,7 +70,7 @@ function renderOperatorInbox() {
`✓ mark all read (${operatorInbox.length})`);
mark.addEventListener('click', markOperatorInboxRead);
root.append(el('div', { class: 'inbox-toolbar' }, mark));
- const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
+ const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'inbox' });
for (const m of operatorInbox) {
const body = el('span', { class: 'msg-body' });
@@ -333,11 +333,12 @@ export function renderApprovals() {
// Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui.md::Approval card).
if (a.requested_at != null) {
- const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at));
+ const requestedSec = epochSec(a.requested_at);
+ const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec));
head.append(el('span', {
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
- title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(),
- 'data-requested-at': String(a.requested_at),
+ title: 'requested ' + new Date(a.requested_at).toLocaleString(),
+ 'data-requested-at': String(requestedSec),
}, 'requested ' + fmtAgo(a.requested_at)));
}
li.append(head);
@@ -556,7 +557,7 @@ function questionRowFingerprint(q) {
// Event listeners attached here (keydown on textarea, submit on form) are
// preserved in the reused node — no re-attachment needed.
function buildQuestionLi(q) {
- const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
+ const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
@@ -570,10 +571,10 @@ function buildQuestionLi(q) {
// Tag the chip with its deadline so the global 1s ticker
// can refresh the text without re-rendering the questions section.
const ttlEl = el('span', {
- class: 'q-ttl', 'data-deadline': String(q.deadline_at),
+ class: 'q-ttl', 'data-deadline': String(epochSec(q.deadline_at)),
});
ttlEl.textContent = formatTtl(
- q.deadline_at - Math.floor(Date.now() / 1000),
+ epochSec(q.deadline_at) - Math.floor(Date.now() / 1000),
);
head.append(' ', ttlEl);
}
@@ -694,7 +695,7 @@ export function renderQuestions() {
//
nodes (preserving textarea/checkbox state) and only rebuilds
// cache-miss rows, so we no longer wipe the DOM at the start.
const openDetails = snapshotOpenDetails(root);
- const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
+ const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
const allPending = questionsState.pending;
// Filter chips. Always include `all` / `operator` / `peer`; add
diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js
index d8cc681e..94ab12f4 100644
--- a/frontend/packages/dashboard/src/flow.js
+++ b/frontend/packages/dashboard/src/flow.js
@@ -18,6 +18,7 @@ import {
appendLinkified,
openStream, initServerWarnings,
} from './common.js';
+import { epochSec } from './util.js';
(() => {
NOTIF.bind();
@@ -92,7 +93,7 @@ import {
const flow = $('msgflow');
if (!flow) return;
flow.replaceChildren();
- const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
+ const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
// Pulse the page banner whenever a broker event lands. The
// `.banner` element lives in the dashboard's