diff --git a/changelog.d/2066-board-done-time.added.md b/changelog.d/2066-board-done-time.added.md new file mode 100644 index 000000000..0c2be90dd --- /dev/null +++ b/changelog.d/2066-board-done-time.added.md @@ -0,0 +1 @@ +A task card in the Done column now says when it finished — a relative time (`2 h`, `Sep 22`) beside the assignee, on a Bot's board and a group's alike ([#2066](https://github.com/ThinkFlowLab/FlyBot/issues/2066)). The moment is the first terminal entry in the card's history (`accepted`, `cancelled` or `failed`), so editing a finished task no longer moves the time; a card recorded before that history existed falls back to its last update, and one with no usable stamp shows no time at all. diff --git a/core/client.js b/core/client.js index 22842a8a5..7e4abcd39 100644 --- a/core/client.js +++ b/core/client.js @@ -8804,6 +8804,12 @@ function canMoveTask(roster, task) { const bot = roster?.byId?.[task?.ownerBotId]; return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); } +var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["accepted", "cancelled", "failed"]); +function finishedAt(task) { + const terminal = (task?.actors ?? []).find((entry) => TERMINAL_STATUSES.has(entry?.status)); + if (Number.isFinite(terminal?.at)) return terminal.at; + return Number.isFinite(task?.updatedAt) ? task.updatedAt : void 0; +} function priorityLabel(priority) { if (priority === "urgent") return t("Highest"); if (priority === "high") return t("High"); @@ -8920,6 +8926,7 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) const who = botName(roster, task); const question = task.waitingFor === "input" ? task.inputRequest?.question : ""; const overall = task.kind === "group"; + const finished = column === "done" ? finishedAt(task) : void 0; return (0, import_react15.createElement)(BoardCard, { card: taskCard(task), skin: TASK_SKIN, @@ -8945,6 +8952,7 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) "span", { className: "bt-board-foot" }, who ? (0, import_react15.createElement)("span", { className: "bt-board-who" }, who) : null, + Number.isFinite(finished) && finished > 0 ? (0, import_react15.createElement)("span", { key: "finished", className: "bt-board-when" }, relativeShort(finished)) : null, (0, import_react15.createElement)( "span", { className: "bt-board-progress" }, @@ -9232,6 +9240,7 @@ var KANBAN_CSS = ` .bt-board-note{font-size:12px;line-height:16px;color:var(--bt-ink-2)} .bt-board-foot{display:flex;align-items:center;gap:8px} .bt-board-who{flex:none;max-width:40%;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-board-when{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} .bt-board-progress{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:6px} .bt-board-progress-bar{flex:1 1 auto;height:4px;border-radius:999px;background:var(--bt-line);overflow:hidden} .bt-board-progress-bar>span{display:block;height:100%;background:var(--bt-accent)} diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index a49039e03..0d0f07872 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -8,6 +8,7 @@ import { BoardCard as BoardCardFrame, TASK_SKIN } from './board-card.js' import { CloseIcon } from './icons.js' import { useEscape } from './overlay-hooks.js' import { t } from './i18n.js' +import { relativeShort } from './text.js' const POLL_MS = 15_000 @@ -113,6 +114,19 @@ export function canMoveTask(roster, task) { return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)) } +// When a card in the Done column finished. `updatedAt` is not that moment: `record()` in +// `src/host/tasks.js:267-270` stamps it on every transition and again on a plain edit (`:1456`, +// `by: 'updated'`), so a card touched after it finished would claim the later time. The first +// terminal entry in `actors` is the transition that ended the task (none of the three has a way +// back out, `:30-39`), while a later entry of the same status is that edit. A row written before +// this history existed falls back to `updatedAt`. +const TERMINAL_STATUSES = new Set(['accepted', 'cancelled', 'failed']) +function finishedAt(task) { + const terminal = (task?.actors ?? []).find(entry => TERMINAL_STATUSES.has(entry?.status)) + if (Number.isFinite(terminal?.at)) return terminal.at + return Number.isFinite(task?.updatedAt) ? task.updatedAt : undefined +} + function priorityLabel(priority) { if (priority === 'urgent') return t('Highest') if (priority === 'high') return t('High') @@ -234,6 +248,9 @@ export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { const who = botName(roster, task) const question = task.waitingFor === 'input' ? task.inputRequest?.question : '' const overall = task.kind === 'group' + // A finished card says when it finished; the other columns keep the footer they had. + // `relativeShort(undefined)` prints `Invalid Date` and `0` prints 1970, so neither draws. + const finished = column === 'done' ? finishedAt(task) : undefined return h(BoardCardFrame, { card: taskCard(task), skin: TASK_SKIN, @@ -257,6 +274,7 @@ export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { ], footer: h('span', { className: 'bt-board-foot' }, who ? h('span', { className: 'bt-board-who' }, who) : null, + Number.isFinite(finished) && finished > 0 ? h('span', { key: 'finished', className: 'bt-board-when' }, relativeShort(finished)) : null, h('span', { className: 'bt-board-progress' }, h('span', { className: 'bt-board-progress-bar', role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': progress, 'aria-label': t('Progress') }, h('span', { style: { width: `${progress}%` } })), @@ -491,6 +509,7 @@ export const KANBAN_CSS = ` .bt-board-note{font-size:12px;line-height:16px;color:var(--bt-ink-2)} .bt-board-foot{display:flex;align-items:center;gap:8px} .bt-board-who{flex:none;max-width:40%;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-board-when{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} .bt-board-progress{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:6px} .bt-board-progress-bar{flex:1 1 auto;height:4px;border-radius:999px;background:var(--bt-line);overflow:hidden} .bt-board-progress-bar>span{display:block;height:100%;background:var(--bt-accent)} diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index 44bfcaf57..6c616f8a6 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -645,3 +645,69 @@ test('#2069: a room board reached from a Bot board counts each active card once assert.equal(byClass(board.column('done'), 'bt-board-card').length, 1, 'the room\'s finished card is drawn once') assert.equal(board.railMeta('s'), '1/2', 'the rail counts the drawn active card once, plus the archive page') }) + +// The Done column says when a card finished; the other columns leave the footer alone. +const HOUR = 3_600_000 +const MINUTE = 60_000 +const cardIn = (column, id) => all(column).find(node => node.props?.['data-task'] === id) +const whenOf = (column, id) => { + const node = byClass(cardIn(column, id), 'bt-board-when')[0] + return node === undefined ? undefined : textOf(node) +} +// A card finished 2 h 5 min ago and edited a minute ago: `updatedAt` and the last actor entry both +// say `1 min`; only the first terminal entry knows when the card really finished. +const editedAfterFinish = (id, extra = {}, status = 'accepted') => task(id, status, { + title: 'Finished and edited', progress: 100, + updatedAt: Date.now() - MINUTE, + actors: [ + { status: 'working', at: Date.now() - 10 * MINUTE }, + { status, at: Date.now() - 125 * MINUTE }, + { status, at: Date.now() - MINUTE, by: 'updated' }, + ], + ...extra, +}) + +// A: the stamp is the first terminal actor entry, not the last one and not `updatedAt`. +test('a Done card says when it finished, from the first terminal actor entry', () => { + const board = boardWith({ kind: 'bot', botId: 's' }, [], [editedAfterFinish('task-done111111')]) + assert.equal(whenOf(board.column('done'), 'task-done111111'), '2 h') +}) + +// B: an unfinished card keeps the footer it had. +test('an In progress card carries no finish time', () => { + const working = task('task-work222222', 'working', { + title: 'Still working', ownerBotId: 's', progress: 40, updatedAt: Date.now() - MINUTE, + actors: [{ status: 'working', at: Date.now() - 10 * MINUTE }], + }) + const board = boardWith({ kind: 'bot', botId: 's' }, [working], []) + const column = board.column('in-progress') + assert.equal(byClass(cardIn(column, 'task-work222222'), 'bt-board-when').length, 0) + assert.match(textOf(cardIn(column, 'task-work222222')), /40%/) +}) + +// C: a row written before the actor history existed falls back to `updatedAt`. +test('a Done card with no actor entry falls back to updatedAt', () => { + const old = task('task-done333333', 'accepted', { + title: 'Finished before the history', ownerBotId: 's', progress: 100, updatedAt: Date.now() - 3 * HOUR, + }) + const board = boardWith({ kind: 'bot', botId: 's' }, [], [old]) + assert.equal(whenOf(board.column('done'), 'task-done333333'), '3 h') +}) + +// D: no stamp at all draws nothing — `relativeShort(undefined)` is `Invalid Date`, and 0 is 1970. +test('a Done card without a usable timestamp draws no time and no Invalid Date', () => { + const board = boardWith({ kind: 'bot', botId: 's' }, [], [ + task('task-done444444', 'accepted', { title: 'No stamp', ownerBotId: 's', progress: 100, updatedAt: undefined }), + task('task-done555555', 'accepted', { title: 'Zero stamp', ownerBotId: 's', progress: 100, updatedAt: 0 }), + ]) + for (const id of ['task-done444444', 'task-done555555']) { + assert.equal(byClass(cardIn(board.column('done'), id), 'bt-board-when').length, 0) + assert.doesNotMatch(textOf(cardIn(board.column('done'), id)), /Invalid Date/) + } +}) + +// E: a room board draws the same node on its Done cards. +test('a room board Done card says when it finished too', () => { + const board = boardWith({ kind: 'group', roomId: 'war' }, [], [editedAfterFinish('task-done666666', { ownerBotId: 'a' })]) + assert.equal(whenOf(board.column('done'), 'task-done666666'), '2 h') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 7c59981d0..20cc23861 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -45,7 +45,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it, and by the details drawer's copy export: `transcriptOf` (`src/client/text.js`) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and `copyBodyOf` drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (`copyNames`; longest first, and no names strips nothing), which keeps a line like `Settings → Models 页面` as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: `core/test/ui-clickthrough.test.js`, `core/test/details-copy.test.js`. | | `core/src/client/board-card.js` | The task board's card frame (#1363): `BoardCard` draws the identity line, the title, the note and the actions the board passes in, with `TASK_SKIN`. A card carries `data-source` (`task`) and `data-key` (`task:`). The caller is `kanban.js`. Tests: `core/test/board-card.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Task-card identity (#1363): `taskCardKey(id)` is `task:` and `taskCard(task)` builds the model `BoardCard` draws. Tests: `core/test/board-card.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. Since #2045 that page is also counted for what it is: when the archive holds a full page (`ARCHIVE_SHOWN`), the Done heading reads `20 newest` and a Bot's rail row reads `20+ done` rather than a fraction that would present the page as the history, and in a group the page is cut across the room, so every Bot's share of it is a lower bound and reads the same way rather than presenting its slice as its own total; a page under the limit keeps `{done}/{total}` exactly as before. P-364: the rail adds up the list the scope actually draws from, and a room board keeps its active cards in a second list of its own (`groupTasks`), so a room rail counts the room's active cards as well as the archive page, reading `1/4` beside four drawn cards and `0/1` for a Bot whose cards are all still active, while a Bot board's numbers are unchanged. #2069: it counts one list and never both — `own` still holds the Bot board's read after the rail switches to a room, and adding it counted the room's active cards twice, so a room board that draws two cards read `1/3` where it now reads `1/2`. P-360: a Bot board's load effect is keyed by the Bot it shows, so switching the rail re-reads the new Bot's active list and archive page — the page it kept was that of the Bot it left, and filtering it by the new Bot emptied its Done column while the poll kept refilling the old one. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. Since #2045 that page is also counted for what it is: when the archive holds a full page (`ARCHIVE_SHOWN`), the Done heading reads `20 newest` and a Bot's rail row reads `20+ done` rather than a fraction that would present the page as the history, and in a group the page is cut across the room, so every Bot's share of it is a lower bound and reads the same way rather than presenting its slice as its own total; a page under the limit keeps `{done}/{total}` exactly as before. P-364: the rail adds up the list the scope actually draws from, and a room board keeps its active cards in a second list of its own (`groupTasks`), so a room rail counts the room's active cards as well as the archive page, reading `1/4` beside four drawn cards and `0/1` for a Bot whose cards are all still active, while a Bot board's numbers are unchanged. #2069: it counts one list and never both — `own` still holds the Bot board's read after the rail switches to a room, and adding it counted the room's active cards twice, so a room board that draws two cards read `1/3` where it now reads `1/2`. P-360: a Bot board's load effect is keyed by the Bot it shows, so switching the rail re-reads the new Bot's active list and archive page — the page it kept was that of the Bot it left, and filtering it by the new Bot emptied its Done column while the poll kept refilling the old one. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. A card in the Done column also says when it finished — a relative time beside the assignee (`bt-board-when`, `relativeShort`), on a Bot's board and a group's alike (#2066). The stamp is the **first** terminal entry in the card's `actors` (`accepted`, `cancelled` or `failed`), never `updatedAt`: `record()` bumps `updatedAt` on a plain edit too, so taking it would name the edit instead of the finish and taking the last terminal entry would do the same. A row written before that history existed falls back to `updatedAt`, and a card with neither a finite `at` nor a finite `updatedAt` draws no node at all — `relativeShort(undefined)` would print `Invalid Date` and `0` would print 1970. The other columns keep the footer they had. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 377e4208e..12f67aeb9 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5135,7 +5135,7 @@ diff --git a/client.js b/client.js .bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} .bt-drawer-name-input{animation:bt-veil-in .16s ease-out} .bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -@@ -2595,2914 +2595,6774 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2595,2914 +2595,6783 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} :is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} @media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} `; @@ -10041,6 +10041,12 @@ diff --git a/client.js b/client.js + const bot = roster?.byId?.[task?.ownerBotId]; + return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); +} ++var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["accepted", "cancelled", "failed"]); ++function finishedAt(task) { ++ const terminal = (task?.actors ?? []).find((entry) => TERMINAL_STATUSES.has(entry?.status)); ++ if (Number.isFinite(terminal?.at)) return terminal.at; ++ return Number.isFinite(task?.updatedAt) ? task.updatedAt : void 0; ++} +function priorityLabel(priority) { + if (priority === "urgent") return t("Highest"); + if (priority === "high") return t("High"); @@ -10189,6 +10195,7 @@ diff --git a/client.js b/client.js + const who = botName(roster, task); + const question = task.waitingFor === "input" ? task.inputRequest?.question : ""; + const overall = task.kind === "group"; ++ const finished = column === "done" ? finishedAt(task) : void 0; + return (0, import_react15.createElement)(BoardCard, { + card: taskCard(task), + skin: TASK_SKIN, @@ -10214,6 +10221,7 @@ diff --git a/client.js b/client.js + "span", + { className: "bt-board-foot" }, + who ? (0, import_react15.createElement)("span", { className: "bt-board-who" }, who) : null, ++ Number.isFinite(finished) && finished > 0 ? (0, import_react15.createElement)("span", { key: "finished", className: "bt-board-when" }, relativeShort(finished)) : null, + (0, import_react15.createElement)( "span", - { className: "bt-set-control" }, @@ -10682,6 +10690,7 @@ diff --git a/client.js b/client.js +.bt-board-note{font-size:12px;line-height:16px;color:var(--bt-ink-2)} +.bt-board-foot{display:flex;align-items:center;gap:8px} +.bt-board-who{flex:none;max-width:40%;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-board-when{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} +.bt-board-progress{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:6px} +.bt-board-progress-bar{flex:1 1 auto;height:4px;border-radius:999px;background:var(--bt-line);overflow:hidden} +.bt-board-progress-bar>span{display:block;height:100%;background:var(--bt-accent)} diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index a49039e03..0d0f07872 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -8,6 +8,7 @@ import { BoardCard as BoardCardFrame, TASK_SKIN } from './board-card.js' import { CloseIcon } from './icons.js' import { useEscape } from './overlay-hooks.js' import { t } from './i18n.js' +import { relativeShort } from './text.js' const POLL_MS = 15_000 @@ -113,6 +114,19 @@ export function canMoveTask(roster, task) { return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)) } +// When a card in the Done column finished. `updatedAt` is not that moment: `record()` in +// `src/host/tasks.js:267-270` stamps it on every transition and again on a plain edit (`:1456`, +// `by: 'updated'`), so a card touched after it finished would claim the later time. The first +// terminal entry in `actors` is the transition that ended the task (none of the three has a way +// back out, `:30-39`), while a later entry of the same status is that edit. A row written before +// this history existed falls back to `updatedAt`. +const TERMINAL_STATUSES = new Set(['accepted', 'cancelled', 'failed']) +function finishedAt(task) { + const terminal = (task?.actors ?? []).find(entry => TERMINAL_STATUSES.has(entry?.status)) + if (Number.isFinite(terminal?.at)) return terminal.at + return Number.isFinite(task?.updatedAt) ? task.updatedAt : undefined +} + function priorityLabel(priority) { if (priority === 'urgent') return t('Highest') if (priority === 'high') return t('High') @@ -234,6 +248,9 @@ export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { const who = botName(roster, task) const question = task.waitingFor === 'input' ? task.inputRequest?.question : '' const overall = task.kind === 'group' + // A finished card says when it finished; the other columns keep the footer they had. + // `relativeShort(undefined)` prints `Invalid Date` and `0` prints 1970, so neither draws. + const finished = column === 'done' ? finishedAt(task) : undefined return h(BoardCardFrame, { card: taskCard(task), skin: TASK_SKIN, @@ -257,6 +274,7 @@ export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { ], footer: h('span', { className: 'bt-board-foot' }, who ? h('span', { className: 'bt-board-who' }, who) : null, + Number.isFinite(finished) && finished > 0 ? h('span', { key: 'finished', className: 'bt-board-when' }, relativeShort(finished)) : null, h('span', { className: 'bt-board-progress' }, h('span', { className: 'bt-board-progress-bar', role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': 100, 'aria-valuenow': progress, 'aria-label': t('Progress') }, h('span', { style: { width: `${progress}%` } })), @@ -491,6 +509,7 @@ export const KANBAN_CSS = ` .bt-board-note{font-size:12px;line-height:16px;color:var(--bt-ink-2)} .bt-board-foot{display:flex;align-items:center;gap:8px} .bt-board-who{flex:none;max-width:40%;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-board-when{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap} .bt-board-progress{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:6px} .bt-board-progress-bar{flex:1 1 auto;height:4px;border-radius:999px;background:var(--bt-line);overflow:hidden} .bt-board-progress-bar>span{display:block;height:100%;background:var(--bt-accent)} diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index 44bfcaf57..6c616f8a6 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -645,3 +645,69 @@ test('#2069: a room board reached from a Bot board counts each active card once assert.equal(byClass(board.column('done'), 'bt-board-card').length, 1, 'the room\'s finished card is drawn once') assert.equal(board.railMeta('s'), '1/2', 'the rail counts the drawn active card once, plus the archive page') }) + +// The Done column says when a card finished; the other columns leave the footer alone. +const HOUR = 3_600_000 +const MINUTE = 60_000 +const cardIn = (column, id) => all(column).find(node => node.props?.['data-task'] === id) +const whenOf = (column, id) => { + const node = byClass(cardIn(column, id), 'bt-board-when')[0] + return node === undefined ? undefined : textOf(node) +} +// A card finished 2 h 5 min ago and edited a minute ago: `updatedAt` and the last actor entry both +// say `1 min`; only the first terminal entry knows when the card really finished. +const editedAfterFinish = (id, extra = {}, status = 'accepted') => task(id, status, { + title: 'Finished and edited', progress: 100, + updatedAt: Date.now() - MINUTE, + actors: [ + { status: 'working', at: Date.now() - 10 * MINUTE }, + { status, at: Date.now() - 125 * MINUTE }, + { status, at: Date.now() - MINUTE, by: 'updated' }, + ], + ...extra, +}) + +// A: the stamp is the first terminal actor entry, not the last one and not `updatedAt`. +test('a Done card says when it finished, from the first terminal actor entry', () => { + const board = boardWith({ kind: 'bot', botId: 's' }, [], [editedAfterFinish('task-done111111')]) + assert.equal(whenOf(board.column('done'), 'task-done111111'), '2 h') +}) + +// B: an unfinished card keeps the footer it had. +test('an In progress card carries no finish time', () => { + const working = task('task-work222222', 'working', { + title: 'Still working', ownerBotId: 's', progress: 40, updatedAt: Date.now() - MINUTE, + actors: [{ status: 'working', at: Date.now() - 10 * MINUTE }], + }) + const board = boardWith({ kind: 'bot', botId: 's' }, [working], []) + const column = board.column('in-progress') + assert.equal(byClass(cardIn(column, 'task-work222222'), 'bt-board-when').length, 0) + assert.match(textOf(cardIn(column, 'task-work222222')), /40%/) +}) + +// C: a row written before the actor history existed falls back to `updatedAt`. +test('a Done card with no actor entry falls back to updatedAt', () => { + const old = task('task-done333333', 'accepted', { + title: 'Finished before the history', ownerBotId: 's', progress: 100, updatedAt: Date.now() - 3 * HOUR, + }) + const board = boardWith({ kind: 'bot', botId: 's' }, [], [old]) + assert.equal(whenOf(board.column('done'), 'task-done333333'), '3 h') +}) + +// D: no stamp at all draws nothing — `relativeShort(undefined)` is `Invalid Date`, and 0 is 1970. +test('a Done card without a usable timestamp draws no time and no Invalid Date', () => { + const board = boardWith({ kind: 'bot', botId: 's' }, [], [ + task('task-done444444', 'accepted', { title: 'No stamp', ownerBotId: 's', progress: 100, updatedAt: undefined }), + task('task-done555555', 'accepted', { title: 'Zero stamp', ownerBotId: 's', progress: 100, updatedAt: 0 }), + ]) + for (const id of ['task-done444444', 'task-done555555']) { + assert.equal(byClass(cardIn(board.column('done'), id), 'bt-board-when').length, 0) + assert.doesNotMatch(textOf(cardIn(board.column('done'), id)), /Invalid Date/) + } +}) + +// E: a room board draws the same node on its Done cards. +test('a room board Done card says when it finished too', () => { + const board = boardWith({ kind: 'group', roomId: 'war' }, [], [editedAfterFinish('task-done666666', { ownerBotId: 'a' })]) + assert.equal(whenOf(board.column('done'), 'task-done666666'), '2 h') +})