Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1896-waited-label-seconds.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions core/test/project-board-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
7 changes: 7 additions & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>` 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.
Expand Down
3 changes: 2 additions & 1 deletion patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions patches/ds-bot/files/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions patches/ds-bot/files/test/project-board-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
Loading