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/2066-board-done-time.added.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand All @@ -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" },
Expand Down Expand Up @@ -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)}
Expand Down
19 changes: 19 additions & 0 deletions core/src/client/kanban.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand All @@ -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}%` } })),
Expand Down Expand Up @@ -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)}
Expand Down
66 changes: 66 additions & 0 deletions core/test/kanban.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Loading
Loading