diff --git a/changelog.d/2619.fixed.md b/changelog.d/2619.fixed.md new file mode 100644 index 000000000..deebde922 --- /dev/null +++ b/changelog.d/2619.fixed.md @@ -0,0 +1 @@ +The timeline panel's **Copy report** now prints the window's hours in your own time zone, the same hours the header above the button shows, instead of the Bot host's: the panel sends its browser zone with the request and the host formats the report in it, including the `(+1 day)` suffix on a window that crosses your midnight. A zone the host cannot use falls back to its own clock, as before ([#2619](https://github.com/ThinkFlowLab/FlyBot/issues/2619)). diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index fa898ec6b..814efe780 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -41,7 +41,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model - Endpoint `timeline/export` (#357, read-only): the current trace mapped to one OTLP/HTTP JSON payload (`resourceSpans`), the same mapping the timer exporter uses. Ids only unless the body sets `names: true` (#483); `serviceName` overrides the default. The panel or an automation can pull it on demand instead of waiting for the next flush. - Endpoint `timeline/health` (#357, read-only): the same window reduced to per-Bot signals and flags (`errors`, `busy`, `idle`, `waiting`) and a team `ok`; the action view over the numbers the panel draws. Body `{ windowMs?, to? }` like `timeline/window`. - Endpoint `timeline/alerts` (#584, read-only): the same window folded across reads into durable alerts — `errors` (a Bot fails most of its turns), `waiting` (its request is unanswered) and `stuck` (a handoff waited past the threshold) — answered as `{ open, resolved, changed, stale }`. Body `{ windowMs?, to? }` like `timeline/window`. A condition opens ONE alert with its `since` and resolves when it clears; a resolved alert stays readable for a minute after it cleared. -- Endpoint `timeline/digest` (#582, read-only): the same window as a short Markdown run report a person can paste into a chat or a standup — per Bot its turns and failures, busy share, tokens (in/out/cached), top tools and unanswered requests, then the team rollup (turns, failures, tokens, calls) and the longest still-open wait. Body `{ windowMs?, to?, title? }`. It reads only what the window carries: `usage`, `toolUse` and `stuck` when present, and nothing is invented when they are absent (`buildDigest`/`formatCount`/`formatMs` in `src/digest.js`, pure). Window hours are the reader's own clock, the same hours the panel header shows, so what a person copies matches what they see above the button. The panel's **Copy report** button asks for it and copies the Markdown; a page that refuses the clipboard says so instead of reporting a copy. +- Endpoint `timeline/digest` (#582, read-only): the same window as a short Markdown run report a person can paste into a chat or a standup — per Bot its turns and failures, busy share, tokens (in/out/cached), top tools and unanswered requests, then the team rollup (turns, failures, tokens, calls) and the longest still-open wait. Body `{ windowMs?, to?, title?, timeZone? }`. It reads only what the window carries: `usage`, `toolUse` and `stuck` when present, and nothing is invented when they are absent (`buildDigest`/`formatCount`/`formatMs` in `src/digest.js`, pure). Window hours are the reader's own clock, the same hours the panel header shows, so what a person copies matches what they see above the button. The panel sends its own browser zone (`timeZone`) with the request, and the host formats the window in it — including the day suffix — falling back to its own clock when the zone is not one it can use (#2619). The panel's **Copy report** button asks for it and copies the Markdown; a page that refuses the clipboard says so instead of reporting a copy. ## Invariants diff --git a/plugins/timeline/client.js b/plugins/timeline/client.js index c37cd3dcc..ffac9cdaa 100644 --- a/plugins/timeline/client.js +++ b/plugins/timeline/client.js @@ -129,6 +129,10 @@ window.__ModuleLoader__.load({ // does, so a report costs one request when a person wants one. async digest({ title } = {}) { const asked = { windowMs: state.windowMs, ...(state.to === undefined ? {} : { to: state.to }) } + // The report prints the reader's clock, so the host is told which zone that is (#2619): the + // same browser zone the header above the button prints (`fmtClock`). + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + if (timeZone) asked.timeZone = timeZone if (typeof title === 'string' && title.trim()) asked.title = title.trim().slice(0, 80) const value = await flybot.call(DIGEST_ENDPOINT, asked) const markdown = typeof value?.markdown === 'string' ? value.markdown : undefined diff --git a/plugins/timeline/src/digest.js b/plugins/timeline/src/digest.js index 67114174c..47c0f264c 100644 --- a/plugins/timeline/src/digest.js +++ b/plugins/timeline/src/digest.js @@ -41,18 +41,48 @@ const percent = (part) => `${Math.round(part * 100)}%` const turnWord = (n) => (n === 1 ? 'turn' : 'turns') const pad = n => String(n).padStart(2, '0') // The reader's own clock, the hours the panel header prints (`fmtClock` in client.js): a report -// copied from the panel must not name a different time than the header above the button. -const clock = (ms) => { +// copied from the panel must not name a different time than the header above the button. The panel +// names the reader's zone with the request (#2619); a host that gets no usable one keeps its own +// clock, exactly as before. +const hostClock = (ms) => { const at = new Date(ms) return Number.isFinite(at.getTime()) ? `${pad(at.getHours())}:${pad(at.getMinutes())}` : '' } // Whole local calendar days between two instants: 0 inside one day, 1 across midnight. -const daysApart = (from, to) => { +const hostDaysApart = (from, to) => { const day = (ms) => { const at = new Date(ms); return Number.isFinite(at.getTime()) ? new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime() : NaN } const a = day(from) const b = day(to) return Number.isFinite(a) && Number.isFinite(b) ? Math.round((b - a) / 86_400_000) : 0 } +const zonePart = (formatter, ms, type) => formatter.formatToParts(ms).find(part => part.type === type)?.value +// The reader's clock in the zone the panel named. A name the runtime will not take is no zone at +// all, so the caller falls back to the host's clock and a bad zone never throws the report away. +const readerZone = (timeZone) => { + if (typeof timeZone !== 'string' || !timeZone.trim()) return undefined + const zone = timeZone.trim() + try { + new Intl.DateTimeFormat('en-CA', { timeZone: zone }) + return { + clock: new Intl.DateTimeFormat('en-CA', { timeZone: zone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }), + day: new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }), + } + } catch { + return undefined + } +} +const zonedClock = (zone, ms) => Number.isFinite(ms) + ? `${zonePart(zone.clock, ms, 'hour')}:${zonePart(zone.clock, ms, 'minute')}` + : '' +// Whole calendar days in the reader's zone, read from that zone's own Y/M/D: a DST day is 23 or 25 +// hours long but still one calendar day, and the host's midnight never decides the suffix (#2619). +const zonedDaysApart = (zone, from, to) => { + if (!Number.isFinite(from) || !Number.isFinite(to)) return 0 + const day = (ms) => Date.UTC(Number(zonePart(zone.day, ms, 'year')), Number(zonePart(zone.day, ms, 'month')) - 1, Number(zonePart(zone.day, ms, 'day'))) + const a = day(from) + const b = day(to) + return Number.isFinite(a) && Number.isFinite(b) ? Math.round((b - a) / 86_400_000) : 0 +} /** * `window` is a `timeline/window` answer. Resolves `{ title, range, live, bots, team, markdown, @@ -60,7 +90,7 @@ const daysApart = (from, to) => { * panel draws it), `team` the rollup, `markdown` the report, `line` a one-line summary a reminder * can post. */ -export function buildDigest(window = {}, { title = 'Team report', now = Date.now() } = {}) { +export function buildDigest(window = {}, { title = 'Team report', now = Date.now(), timeZone } = {}) { const lanes = new Map((window.lanes ?? []).map(lane => [lane.id, lane])) const usageBy = new Map((window.usage ?? []).map(use => [use.lane, use])) const toolUseBy = new Map((window.toolUse ?? []).map(use => [use.lane, use.tools ?? []])) @@ -121,11 +151,13 @@ export function buildDigest(window = {}, { title = 'Team report', now = Date.now unhealthy: bots.filter(bot => bot.counted >= ERROR_TURNS_MIN && bot.errorRate >= ERROR_RATE_MIN).map(bot => bot.id), } + const zone = readerZone(timeZone) + const clock = zone ? ms => zonedClock(zone, ms) : hostClock + const days = zone ? zonedDaysApart(zone, window.from, window.to) : hostDaysApart(window.from, window.to) const from = clock(window.from) const to = clock(window.to) // The clock prints HH:MM only, so a range that crosses local midnight would read backwards // (#1787): name the extra days instead (`23:50–00:10 (+1 day)`), and leave a same-day range alone. - const days = daysApart(window.from, window.to) const range = from && to ? `${from}–${to}${days > 0 ? ` (+${days} ${days === 1 ? 'day' : 'days'})` : ''}` : 'the window' const lines = [`# ${title}`, `Window: ${range}${window.live === true ? ' · live' : ''}`] diff --git a/plugins/timeline/src/timeline.js b/plugins/timeline/src/timeline.js index 3dbcd975a..941522e9f 100644 --- a/plugins/timeline/src/timeline.js +++ b/plugins/timeline/src/timeline.js @@ -64,7 +64,15 @@ export function createTimeline(adapter, { dir, persist = true, now = Date.now, e const alertView = (body = {}) => alerts.fold(window(body)) const offAlerts = adapter.handle(ALERTS_ENDPOINT, alertView) // The team run report (#582): the same window, as short Markdown a person can paste. Read-only. - const digest = (body = {}) => buildDigest(window(body), { ...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.trim().slice(0, 80) } : {}), now: now() }) + // The panel names the reader's zone (#2619); it is passed through only when it looks like one. + const digest = (body = {}) => { + const timeZone = typeof body?.timeZone === 'string' ? body.timeZone.trim() : '' + return buildDigest(window(body), { + ...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.trim().slice(0, 80) } : {}), + ...(timeZone && timeZone.length <= 64 ? { timeZone } : {}), + now: now(), + }) + } const offDigest = adapter.handle(DIGEST_ENDPOINT, digest) return { trace, recorder, window, exportNow, exportWindow, health, alerts, alertView, digest, diff --git a/plugins/timeline/test/digest.test.js b/plugins/timeline/test/digest.test.js index dc0b94699..d96bb578d 100644 --- a/plugins/timeline/test/digest.test.js +++ b/plugins/timeline/test/digest.test.js @@ -278,3 +278,79 @@ test('#2543: a Bot busy the whole window reads as busy, not as a turn-less Bot', // Control: a Bot with no turn and no busy time still reads as idle, exactly as before. assert.match(buildDigest(make(), { now: 100_000 }).markdown, /## Writer\n- no turns in the window/) }) + +// #2619: the report's hours are the reader's, and only the panel knows which zone that is. The host +// formats the window in the zone the panel sends; without that, a host in another zone printed its +// own hours next to a panel header showing the browser's (`fmtClock`), and a Shanghai reader behind +// a UTC host got `15:30–16:30` above a header that said `23:30–00:30`. +const READER_FROM = Date.parse('2026-11-06T15:30:00Z') +const READER_TO = Date.parse('2026-11-06T16:30:00Z') +const readerWindow = () => make({ from: READER_FROM, to: READER_TO, live: false }) + +test('#2619: the report prints the window in the zone the panel names', () => { + const zone = timeZone => buildDigest(readerWindow(), { now: READER_TO, timeZone }).range + assert.equal(zone('UTC'), '15:30–16:30') + assert.equal(zone('Asia/Shanghai'), '23:30–00:30 (+1 day)') + assert.equal(zone('America/New_York'), '10:30–11:30') + // The day suffix follows the reader's midnight too, not the host's: UTC crosses none, Shanghai + // crosses its own, and New York stays inside one day. + assert.equal(buildDigest(readerWindow(), { now: READER_TO, timeZone: 'UTC' }).line, '4 turns, 1 failed, 12.3k tokens in over 15:30–16:30.') +}) + +// A zone the browser named but the host cannot use must never break the report, and must never be +// silently replaced by another zone's hours: it falls back to today's host-zone answer. +test('#2619: an unknown zone falls back to the host zone and never throws', () => { + const fallback = buildDigest(readerWindow(), { now: READER_TO }) + for (const timeZone of ['Mars/Olympus', '', ' ', null, 42, {}, 'A'.repeat(200)]) { + const digest = buildDigest(readerWindow(), { now: READER_TO, timeZone }) + assert.equal(digest.range, fallback.range, String(timeZone)) + assert.equal(digest.markdown, fallback.markdown, String(timeZone)) + } +}) + +// Without a zone the report is what it printed before this change, byte for byte: the host's own +// clock, as a reminder or an older panel that names no zone still gets. +test('#2619: no zone keeps the report byte for byte as it was', () => { + const digest = buildDigest(readerWindow(), { now: READER_TO }) + assert.equal(digest.markdown, [ + '# Team report', + 'Window: 23:30–00:30 (+1 day)', + '', + '## Chief (main)', + '- 4 turns, 1 failed (25%)', + '- busy 62% of the window', + '- tokens: 12.3k in / 1.2k out / 8.0k cached', + '- tools: web_search ×6 (1 failed), read ×2', + '', + '## Writer', + '- no turns in the window', + '- 1 request(s) unanswered', + '- look: 1 request(s) unanswered', + '', + '## Team', + '- 4 turns, 1 failed', + '- tokens: 12.3k in / 1.2k out (4 calls)', + '- longest wait: b → a, 12m', + ].join('\n')) + assert.equal(digest.line, '4 turns, 1 failed, 12.3k tokens in over 23:30–00:30 (+1 day).') +}) + +// The zone crosses the wire as `body.timeZone`, trimmed and length-capped; anything else is not a +// zone and leaves the request exactly as it was. +test('#2619: the endpoint passes the panel\'s zone through and ignores anything else', () => { + const adapter = mockAdapter() + const timeline = createTimeline(adapter, { persist: false, now: () => Date.UTC(2026, 10, 6, 16, 30) }) + try { + const ask = adapter.endpoints.get(DIGEST_ENDPOINT) + assert.equal(ask({ windowMs: 3_600_000, timeZone: 'UTC' }).range, '15:30–16:30') + assert.equal(ask({ windowMs: 3_600_000, timeZone: ' Asia/Shanghai ' }).range, '23:30–00:30 (+1 day)') + // No zone, and every shape that is not one: the host's own clock, exactly as before. + const host = ask({ windowMs: 3_600_000 }).range + assert.equal(host, '23:30–00:30 (+1 day)') + assert.equal(ask({ windowMs: 3_600_000, timeZone: 42 }).range, host) + assert.equal(ask({ windowMs: 3_600_000, timeZone: ' ' }).range, host) + assert.equal(ask({ windowMs: 3_600_000, timeZone: 'A'.repeat(65) }).range, host) + } finally { + timeline.dispose() + } +}) diff --git a/plugins/timeline/test/panel.test.js b/plugins/timeline/test/panel.test.js index 34c3f12a2..ba268d596 100644 --- a/plugins/timeline/test/panel.test.js +++ b/plugins/timeline/test/panel.test.js @@ -543,12 +543,15 @@ test('#582: the data layer asks for the report at the window the panel shows', a const flybot = { call: async (endpoint, body) => { asked.push([endpoint, body]); return { markdown: '# Team report', line: '4 turns' } } } const store = client.createStore(flybot, { windowMs: 300_000 }) const report = await store.digest() - assert.deepEqual(asked, [['timeline/digest', { windowMs: 300_000 }]]) + // The report prints the reader's clock, so the request names the browser's zone (#2619) — the + // same one the header prints. A page without one sends no zone and the host keeps its own clock. + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone + assert.deepEqual(asked, [['timeline/digest', { windowMs: 300_000, timeZone: zone }]]) assert.equal(report.markdown, '# Team report') assert.equal(report.line, '4 turns') store.setWindow({ windowMs: 60_000 }) await store.digest({ title: ' Standup ' }) - assert.deepEqual(asked.at(-1), ['timeline/digest', { windowMs: 60_000, title: 'Standup' }]) + assert.deepEqual(asked.at(-1), ['timeline/digest', { windowMs: 60_000, timeZone: zone, title: 'Standup' }]) // A host that answers nothing usable is an error, not an empty report. const silent = client.createStore({ call: async () => ({}) }, {}) await assert.rejects(() => silent.digest(), /no report/)