From 8728d40f80f00ddff1737540f8b3414205d3e160 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:10:58 +0800 Subject: [PATCH] timeline digest: a count that rounds into the next unit carries, instead of printing 1000.0k (#1779) The k unit was picked from the raw value and the rounding happened inside it, so 999,950-999,999 printed 1000.0k, a number the unit cannot hold. Round once and carry into M, the way #761 already carried a duration into the next unit. --- changelog.d/1779-digest-count-rollover.fixed.md | 1 + docs/subsystems/timeline.md | 2 +- plugins/timeline/src/digest.js | 9 ++++++++- plugins/timeline/test/digest.test.js | 11 +++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1779-digest-count-rollover.fixed.md diff --git a/changelog.d/1779-digest-count-rollover.fixed.md b/changelog.d/1779-digest-count-rollover.fixed.md new file mode 100644 index 000000000..14059adf7 --- /dev/null +++ b/changelog.d/1779-digest-count-rollover.fixed.md @@ -0,0 +1 @@ +Fix the team run report printing `1000.0k` for a count between 999,950 and 999,999; it now carries into the next unit and reads `1.0M` ([#1779](https://github.com/ThinkFlowLab/FlyBot/issues/1779)). diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index b253eb594..161bd9599 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`). 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/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`. A count carries the same way (#1779): 999,950 reads `1.0M`, never `1000.0k`. | | `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 fa5ce2748..1478e2d52 100644 --- a/plugins/timeline/src/digest.js +++ b/plugins/timeline/src/digest.js @@ -14,7 +14,14 @@ const TOP_TOOLS = 5 export function formatCount(value) { const n = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0 if (n >= M) return `${(n / M).toFixed(1)}M` - if (n >= K) return `${(n / K).toFixed(1)}k` + if (n >= K) { + // Carry a count that rounds up into the next unit (#1779), the same carry #761 fixed for + // durations: picking the unit from the raw value and rounding inside it printed `1000.0k` for + // 999,950–999,999, a number the k unit cannot hold. + const tenths = Math.round(n / 100) + if (tenths >= 10_000) return `${(tenths / 10_000).toFixed(1)}M` + return `${(n / K).toFixed(1)}k` + } return String(n) } diff --git a/plugins/timeline/test/digest.test.js b/plugins/timeline/test/digest.test.js index 8e8910378..434a3b4fd 100644 --- a/plugins/timeline/test/digest.test.js +++ b/plugins/timeline/test/digest.test.js @@ -36,6 +36,17 @@ test('formatCount and formatMs read at a glance', () => { assert.equal(formatMs(undefined), '0s') }) +// The unit is picked from the raw value, so the rounding inside it can overflow it (#1779): a count +// one step below a million printed `1000.0k`, which is a number its own unit cannot hold and one a +// reader has to convert back before it means anything. The carry is the same one #761 fixed for +// durations. +test('#1779: a count that rounds up into the next unit prints that unit, not 1000.0k', () => { + assert.equal(formatCount(999_949), '999.9k') + assert.equal(formatCount(999_950), '1.0M') + assert.equal(formatCount(999_999), '1.0M') + assert.equal(formatCount(1_000_000), '1.0M') +}) + // 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.