diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 23472a1..faf9f37 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -47,6 +47,10 @@ .banner.updated { border-color: color-mix(in srgb, var(--blue-fg, var(--fg-2)) 45%, var(--line)); background: color-mix(in srgb, var(--blue-fg, var(--fg-2)) 7%, var(--surface)); } .banner.updated button { margin: 0 0 0 8px; } .held { position: fixed; right: 16px; bottom: 64px; z-index: 30; margin: 0; box-shadow: var(--shadow); } + /* fixed, so it never moves what is under the cursor; at the top, as both bottom corners are taken, + over the content, not the nav (below it on a phone, where staleNote measures the nav), under a + drawer or review, and never in the way of a click */ + .stale-note { position: fixed; top: 12px; left: calc(232px + 16px); right: 16px; width: fit-content; margin: 0 auto; z-index: 19; pointer-events: none; padding: 4px 12px; text-align: center; border: 1px solid color-mix(in srgb, var(--amber) 45%, var(--line)); border-radius: 12px; background: color-mix(in srgb, var(--amber) 12%, var(--surface)); color: var(--amber-fg); font-size: 12px; box-shadow: var(--shadow); } .banner b { color: var(--red-fg); } .banner pre { margin: 6px 0 0; max-height: 9em; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.5 var(--mono); color: var(--fg-2); } .banner.stale { border-color: color-mix(in srgb, var(--amber) 45%, var(--line)); background: color-mix(in srgb, var(--amber) 7%, var(--surface)); } @@ -78,6 +82,7 @@ .side .brand { padding: 0 8px 0 0; } .side .nav { display: flex; gap: 2px; } .side .foot { display: none; } + .stale-note { top: 60px; left: 16px; } .nav a { white-space: nowrap; } /* a long URL or id wraps; it doesn't widen the page */ main { padding: 16px 16px 64px; overflow-wrap: anywhere; } @@ -581,6 +586,7 @@ +

Loading…

@@ -4003,6 +4009,7 @@

${linkify(k.statement)}

// load there is nothing else to see, and after it the page would otherwise show old data as current. function showFailure(message) { const b = $('banner'); + $('stale').hidden = true; // the banner says what is shown, and why $('loading').hidden = true; const first = !DATA; const fix = /needs Node 18|is not a function|SyntaxError|Unexpected token/.test(message) @@ -5066,6 +5073,19 @@

${linkify(k.statement)}

$('loading').hidden = true; document.querySelector('main').classList.remove('waiting'); drawOrHold(d); + staleNote(); +} +// The server answers a slow read with the last data it had, marked stale: say so where it shows on +// any screen, with the time of what is drawn (not of a read held while the owner types), until a +// fresh read lands. A banner at the top says more, and the note gives way to it. +function staleNote() { + const n = $('stale'); + n.hidden = !STALE || !DATA || !$('banner').hidden || !$('updated').hidden; + if (n.hidden) return; + const at = new Date(DATA.generated_at); + const t = `Showing data from ${at.toDateString() === new Date().toDateString() ? at.toLocaleTimeString() : at.toLocaleString()}. Newer data is taking a while to load; this page will update by itself.`; + if (n.textContent !== t) n.textContent = t; // unchanged, a screen reader isn't told again + n.style.top = matchMedia('(max-width: 900px)').matches ? `${Math.max(0, document.querySelector('.side').getBoundingClientRect().bottom) + 8}px` : ''; } // The tab title carries the Inbox count. Notifications are the hub's, for every instance. diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index 236ed17..5e4fc69 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -709,6 +709,36 @@ test('a slow data run: a read with data to fall back on has it at once, marked s await until(async () => (await read()).asked.includes('Second?'), 'the run landing'); }); +test('a stale read is said on the page: a note at the top with the time of what is shown, on a phone too', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 120000 }, async (t) => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-stale-note-')); + t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + // waiting no time at all on a run: every read after the first is the last good data, marked stale + const env = { ...process.env, DEVTEAM_DATA_WAIT_MS: '0', DEVTEAM_HUB_PORT: String(PORTS + 42), DEVTEAM_PORT_BASE: String(PORTS + 43), CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; + for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR', 'TMUX', 'TMUX_PANE']) delete env[k]; + const root = bareProject(base, 'stale'); + const port = await freePort(); + const dash = startDashboard(root, env, ['--port', String(port)]); + t.after(() => dash.stop()); + await until(() => /dashboard on http/.test(dash.out()), 'the dashboard'); + const chrome = await openChrome(); + t.after(() => chrome.close()); + const p = await chrome.newPage(); + t.after(() => p.close()); + await p.send('Page.enable'); + await p.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 800, deviceScaleFactor: 1, mobile: true }); + await p.send('Page.addScriptToEvaluateOnNewDocument', { source: POLL_FAST }); + await p.send('Page.navigate', { url: `http://127.0.0.1:${port}/` }); + const soon = soonIn(p, 30000, () => dash.out()); + await soon(`DATA && window.__reads?.length >= 1 && window.__reads[0].ms !== null`, 'the first read drawn'); + // one check, in the page, so a read landing between two can't split them: shown, inside a phone's + // width, below the nav, naming the time of what is drawn + await soon(`(() => { + const n = $('stale'), r = n.getBoundingClientRect(), nav = document.querySelector('.side').getBoundingClientRect(); + const want = 'Showing data from ' + new Date(DATA.generated_at).toLocaleTimeString() + '. Newer data is taking a while to load; this page will update by itself.'; + return !n.hidden && getComputedStyle(n).display !== 'none' && r.left >= 0 && r.right <= innerWidth && r.top >= nav.bottom && n.textContent === want; + })()`, 'the note, once a read is stale, saying what it shows'); +}); + test('the dashboard and hub pages parse: one stray quote and nothing shows at all', () => { for (const page of ['index.html', 'hub.html']) { const html = fs.readFileSync(path.join(kit, 'dashboard', page), 'utf8');