From 463907e9cc14d9565f2d59c79ed08dcab571d3a1 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:57:28 +0800 Subject: [PATCH] Read a sub-minute wait in seconds on the Project board A card that waited 40 seconds read `0m` on the board's Waiting group and on all three duration metrics (PR merge time, issue close time, task completion), so a 35-second median merge looked like "instant or unknown". `waitedLabel` now carries seconds under a minute and never floors a span over zero to `0s`; missing and negative spans still return nothing, and an unread duration stays the dash. `elapsedLabel` next door already avoided `0m` this way, so the two labels now agree. Fixes #1896 --- .../1896-waited-label-seconds.fixed.md | 1 + core/client.js | 1 + core/src/client/project-board.js | 6 ++-- core/test/project-board-view.test.js | 33 +++++++++++++++++++ docs/subsystems/core-client.md | 7 ++++ patches/ds-bot/0008-client-bundle.patch | 3 +- .../ds-bot/files/src/client/project-board.js | 6 ++-- .../files/test/project-board-view.test.js | 33 +++++++++++++++++++ 8 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1896-waited-label-seconds.fixed.md diff --git a/changelog.d/1896-waited-label-seconds.fixed.md b/changelog.d/1896-waited-label-seconds.fixed.md new file mode 100644 index 000000000..e3618fc92 --- /dev/null +++ b/changelog.d/1896-waited-label-seconds.fixed.md @@ -0,0 +1 @@ +The Project board's wait label now carries seconds under a minute, so a card that waited 40 seconds reads `40s` instead of `0m`; the three duration metrics (PR merge time, issue close time, task completion) use the same label, and a span over zero never floors to `0s`. Unknown and negative spans still render nothing. ([#1896](https://github.com/ThinkFlowLab/FlyBot/issues/1896)) diff --git a/core/client.js b/core/client.js index 84707c182..aeef9850b 100644 --- a/core/client.js +++ b/core/client.js @@ -10707,6 +10707,7 @@ function ProjectDoneGroup({ view, onOpen }) { } function waitedLabel(ms) { if (!Number.isFinite(ms) || ms < 0) return null; + if (ms < 6e4) return `${ms === 0 ? 0 : Math.max(1, Math.floor(ms / 1e3))}s`; const minutes = Math.floor(ms / 6e4); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index afbf28a89..4f8e0cbbc 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -369,10 +369,12 @@ function ProjectDoneGroup({ view, onOpen }) { view.done.rows.map(row => projectRowNode(row, onOpen))) } -// How long a card has waited, in the unit a reader wants: minutes under an hour, hours under a -// day. A card with no `createdAt` says nothing rather than "0m". +// How long a card has waited, in the unit a reader wants: seconds under a minute, minutes under an +// hour, hours under a day. A card with no `createdAt` says nothing rather than "0m", and a span over +// zero never floors to "0s" — the way `elapsedLabel` never says "0m" (#1896). export function waitedLabel(ms) { if (!Number.isFinite(ms) || ms < 0) return null + if (ms < 60000) return `${ms === 0 ? 0 : Math.max(1, Math.floor(ms / 1000))}s` const minutes = Math.floor(ms / 60000) if (minutes < 60) return `${minutes}m` const hours = Math.floor(minutes / 60) diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index fb73f3127..c4310a890 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -263,6 +263,39 @@ test('#1768: the view carries the three groups and the header counts', () => { assert.equal(waitedLabel(5 * 60 * 1000), '5m') }) +// #1896: a span under a minute read `0m`, on the card's wait line and on all three duration metrics, +// so a 40-second wait and a 35-second median merge both claimed "no wait". The label carries seconds +// under a minute, the way `elapsedLabel` next door never says `0m`; zero still reads zero and an +// unknown span still says nothing. +test('#1896: a sub-minute wait and a sub-minute median read in seconds, never 0', () => { + assert.equal(waitedLabel(40 * 1000), '40s') + assert.equal(waitedLabel(59 * 1000 + 999), '59s') + assert.equal(waitedLabel(0), '0s') + assert.equal(waitedLabel(1), '1s', 'a span over zero never floors to 0') + // The units at and above a minute are unchanged. + assert.equal(waitedLabel(60 * 1000), '1m') + assert.equal(waitedLabel(3 * 3600 * 1000), '3h') + assert.equal(waitedLabel(50 * 24 * 3600 * 1000), '50d') + // Unknown stays unknown: nothing renders, rather than 0. + assert.equal(waitedLabel(null), null) + assert.equal(waitedLabel(Number.NaN), null) + assert.equal(waitedLabel(-1), null) + // The three duration metrics go through the same label, so they read seconds too. + const view = plain(metricsView({ ok: true, metrics: { prMergeMedianMs: 40 * 1000, issueCloseMedianMs: 59 * 1000 + 999, taskDoneMedianMs: 0 } })) + assert.equal(view.cards.find(card => card.key === 'prMergeMedianMs').value, '40s') + assert.equal(view.cards.find(card => card.key === 'issueCloseMedianMs').value, '59s') + assert.equal(view.cards.find(card => card.key === 'taskDoneMedianMs').value, '0s') + const unknown = plain(metricsView({ ok: true, metrics: { prMergeMedianMs: null, issueCloseMedianMs: -1 } })) + assert.equal(unknown.cards.find(card => card.key === 'prMergeMedianMs').value, METRIC_DASH) + assert.equal(unknown.cards.find(card => card.key === 'issueCloseMedianMs').value, METRIC_DASH) + // And the card's own wait line draws that same label. + const subMinute = { ...queues, waiting: [{ id: 'task-w3', role: 'Dev', since: 300, waitedMs: 40 * 1000 }] } + const nodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues: subMinute })) + const text = nodes.map(textOf).join(' | ') + assert.ok(text.includes('· 40s'), text) + assert.ok(!text.includes('0m'), text) +}) + test('#1768: an unreadable count stays unknown and is never rendered as 0', () => { const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows: [], tasks: [], diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e57dba3fb..b8ad95e90 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -374,6 +374,13 @@ along with `tasks` in the same response. A count the host answered as `null` ren never as `0`; a group with no items is not drawn, and a board with neither rows nor groups is still the empty state. Rows keep their task id, so `#task=` still opens the card. +The wait a card shows is one label for the whole board (#1896): `waitedLabel` reads seconds under a +minute, minutes under an hour, hours under a day, and a span over zero never floors to `0s` — a card that +waited 40 seconds reads `40s`, not `0m`, the way `elapsedLabel` never says `0m`. The three duration +metrics go through that same function, so a 35-second median merge reads `35s`; a missing or negative +span still returns nothing and an unread duration stays the dash. Tests: +`core/test/project-board-view.test.js`. + The board's three column names go through the same dictionary as the task board: `not-started`, `in-progress` and `done` are keys in `i18n.js`, so a zh UI reads 未开始 / 进行中 / 已完成 instead of the raw status. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 070787ea6..0c2147401 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -12248,7 +12248,7 @@ diff --git a/client.js b/client.js setOpen(false); }; const onKey = (event) => { -@@ -5097,412 +5097,1329 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5097,412 +5097,1330 @@ function UpdateButton({ useUpdate, actions }) { window.removeEventListener("keydown", onKey, true); }; }, [open]); @@ -12982,6 +12982,7 @@ diff --git a/client.js b/client.js - if (shellShortcuts === shortcuts) shellShortcuts = null; +function waitedLabel(ms) { + if (!Number.isFinite(ms) || ms < 0) return null; ++ if (ms < 6e4) return `${ms === 0 ? 0 : Math.max(1, Math.floor(ms / 1e3))}s`; + const minutes = Math.floor(ms / 6e4); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index afbf28a89..4f8e0cbbc 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -369,10 +369,12 @@ function ProjectDoneGroup({ view, onOpen }) { view.done.rows.map(row => projectRowNode(row, onOpen))) } -// How long a card has waited, in the unit a reader wants: minutes under an hour, hours under a -// day. A card with no `createdAt` says nothing rather than "0m". +// How long a card has waited, in the unit a reader wants: seconds under a minute, minutes under an +// hour, hours under a day. A card with no `createdAt` says nothing rather than "0m", and a span over +// zero never floors to "0s" — the way `elapsedLabel` never says "0m" (#1896). export function waitedLabel(ms) { if (!Number.isFinite(ms) || ms < 0) return null + if (ms < 60000) return `${ms === 0 ? 0 : Math.max(1, Math.floor(ms / 1000))}s` const minutes = Math.floor(ms / 60000) if (minutes < 60) return `${minutes}m` const hours = Math.floor(minutes / 60) diff --git a/patches/ds-bot/files/test/project-board-view.test.js b/patches/ds-bot/files/test/project-board-view.test.js index fb73f3127..c4310a890 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -263,6 +263,39 @@ test('#1768: the view carries the three groups and the header counts', () => { assert.equal(waitedLabel(5 * 60 * 1000), '5m') }) +// #1896: a span under a minute read `0m`, on the card's wait line and on all three duration metrics, +// so a 40-second wait and a 35-second median merge both claimed "no wait". The label carries seconds +// under a minute, the way `elapsedLabel` next door never says `0m`; zero still reads zero and an +// unknown span still says nothing. +test('#1896: a sub-minute wait and a sub-minute median read in seconds, never 0', () => { + assert.equal(waitedLabel(40 * 1000), '40s') + assert.equal(waitedLabel(59 * 1000 + 999), '59s') + assert.equal(waitedLabel(0), '0s') + assert.equal(waitedLabel(1), '1s', 'a span over zero never floors to 0') + // The units at and above a minute are unchanged. + assert.equal(waitedLabel(60 * 1000), '1m') + assert.equal(waitedLabel(3 * 3600 * 1000), '3h') + assert.equal(waitedLabel(50 * 24 * 3600 * 1000), '50d') + // Unknown stays unknown: nothing renders, rather than 0. + assert.equal(waitedLabel(null), null) + assert.equal(waitedLabel(Number.NaN), null) + assert.equal(waitedLabel(-1), null) + // The three duration metrics go through the same label, so they read seconds too. + const view = plain(metricsView({ ok: true, metrics: { prMergeMedianMs: 40 * 1000, issueCloseMedianMs: 59 * 1000 + 999, taskDoneMedianMs: 0 } })) + assert.equal(view.cards.find(card => card.key === 'prMergeMedianMs').value, '40s') + assert.equal(view.cards.find(card => card.key === 'issueCloseMedianMs').value, '59s') + assert.equal(view.cards.find(card => card.key === 'taskDoneMedianMs').value, '0s') + const unknown = plain(metricsView({ ok: true, metrics: { prMergeMedianMs: null, issueCloseMedianMs: -1 } })) + assert.equal(unknown.cards.find(card => card.key === 'prMergeMedianMs').value, METRIC_DASH) + assert.equal(unknown.cards.find(card => card.key === 'issueCloseMedianMs').value, METRIC_DASH) + // And the card's own wait line draws that same label. + const subMinute = { ...queues, waiting: [{ id: 'task-w3', role: 'Dev', since: 300, waitedMs: 40 * 1000 }] } + const nodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues: subMinute })) + const text = nodes.map(textOf).join(' | ') + assert.ok(text.includes('· 40s'), text) + assert.ok(!text.includes('0m'), text) +}) + test('#1768: an unreadable count stays unknown and is never rendered as 0', () => { const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows: [], tasks: [],