diff --git a/changelog.d/761-format-ms-carry.fixed.md b/changelog.d/761-format-ms-carry.fixed.md new file mode 100644 index 000000000..a0957e21d --- /dev/null +++ b/changelog.d/761-format-ms-carry.fixed.md @@ -0,0 +1 @@ +Durations in the run report no longer overflow their own unit: a wait of 59.6 s reads `1m` instead of `60s`, and 1h59m50s reads `2h00m` instead of `1h60m` ([#761](https://github.com/ThinkFlowLab/FlyBot/issues/761)). diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index 438442773..b253eb594 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -28,7 +28,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model | `plugins/timeline/src/store.js` | The trace: single writer, write-tmp + fsync + rename, capped segments. | | `plugins/timeline/src/window.js` | Pure read model: records to lanes, turns, tools, arrows, edges, utilization, the token spend per Bot (#357), per-Bot outcomes and recent failures (#471), the open requests waiting longest (#469) and the tools each Bot used (#359). | | `plugins/timeline/src/health.js` | Team health (#357): pure `buildHealth(window)` turning the window into per-Bot signals and flags. | -| `plugins/timeline/src/digest.js` | Team run report (#582): pure `buildDigest(window)` turning the window into short Markdown, with the count and duration formatting (`formatCount`, `formatMs`). | +| `plugins/timeline/src/digest.js` | Team run report (#582): pure `buildDigest(window)` turning the window into short Markdown, with the count and duration formatting (`formatCount`, `formatMs`). A duration rounds once and carries into the next unit (#761), so the text says `1m` and `2h00m`, never `60s`, `60m` or `1h60m`. | | `plugins/timeline/src/alerts.js` | Durable alerts (#584): pure `createAlertTracker()` folding a window across reads, so a condition opens one alert and resolves when it clears. | | `plugins/timeline/client.js` | Browser entry (`@flybot/timeline`): the 2 s poller exposed as `ctx.flybot.timeline`, the geometry functions (`layoutSwimlanes`, `layoutMap`, `waitRows`, `utilizationRows`) and the panel (swimlanes, who waits on whom, utilization, and **Copy report**), opened from its `flybot.sidebar.section` row, the only entry (not in the + menu, #269). | | `plugins/timeline/src/sanitize.js` | The cleaners for ids, labels, codes, times, counts. | diff --git a/plugins/timeline/src/digest.js b/plugins/timeline/src/digest.js index 8fa15e875..2b003e1bf 100644 --- a/plugins/timeline/src/digest.js +++ b/plugins/timeline/src/digest.js @@ -21,10 +21,13 @@ export function formatCount(value) { /** A duration a person reads at a glance: `40s`, `12m`, `1h05m`. */ export function formatMs(ms) { const value = Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : 0 - if (value < 60_000) return `${Math.round(value / 1000)}s` - if (value < 3_600_000) return `${Math.round(value / 60_000)}m` - const minutes = Math.round((value % 3_600_000) / 60_000) - return `${Math.floor(value / 3_600_000)}h${String(minutes).padStart(2, '0')}m` + // Round once and carry into the next unit, so the text never prints a count its own unit cannot + // hold (#761): picking the unit from the raw value and rounding inside it printed `60s` for + // 59.6 s, `60m` for 59m59.999s and `1h60m` for 1h59m50s. + if (Math.round(value / 1000) < 60) return `${Math.round(value / 1000)}s` + const minutes = Math.round(value / 60_000) + if (minutes < 60) return `${minutes}m` + return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m` } const percent = (part) => `${Math.round(part * 100)}%` diff --git a/plugins/timeline/test/digest.test.js b/plugins/timeline/test/digest.test.js index a036ef91e..411d55750 100644 --- a/plugins/timeline/test/digest.test.js +++ b/plugins/timeline/test/digest.test.js @@ -32,9 +32,25 @@ test('formatCount and formatMs read at a glance', () => { assert.equal(formatMs(40_000), '40s') assert.equal(formatMs(720_000), '12m') assert.equal(formatMs(3_900_000), '1h05m') + assert.equal(formatMs(3_600_000), '1h00m') assert.equal(formatMs(undefined), '0s') }) +// The carry must never overflow the unit the text prints (#761): rounding the raw milliseconds +// inside a unit chosen from the raw value printed `60s` for 59.6 s and `1h60m` for 1h59m50s, and a +// person pasting the report read those numbers in a standup. +test('#761: a duration rounds once and carries, instead of printing 60s, 60m or 1h60m', () => { + assert.equal(formatMs(59_600), '1m') + assert.equal(formatMs(3_599_999), '1h00m') + assert.equal(formatMs(7_190_000), '2h00m') + // The boundaries themselves stay where they were. + assert.equal(formatMs(59_400), '59s') // 59.4 s still reads in seconds + assert.equal(formatMs(59_999), '1m') // 59.999 s rounds to a minute, never to '60s' + assert.equal(formatMs(60_000), '1m') + assert.equal(formatMs(3_540_000), '59m') + assert.equal(formatMs(86_400_000), '24h00m') +}) + test('#582: per-Bot sections, the team rollup and the longest wait', () => { const digest = buildDigest(make(), { now: 100_000 }) assert.equal(digest.bots[0].name, 'Chief')