Skip to content

Commit df35aa3

Browse files
committed
fix(board): archive finished task cards and complete an overall card
A group that has finished hundreds of cards kept shipping every one of them to each board read, and an overall card stayed working after its last child completed. The tasks read now returns active work only; `archive: true` is the same read including the finished rows, so the client's Done column shows a bounded slice of it. Finishing or cancelling the last open child completes the overall card through the same move a person would make. Fixes #1252 Written-by: Dev-1
1 parent 99b1fab commit df35aa3

15 files changed

Lines changed: 339 additions & 90 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A finished task card leaves the task board's active list, and the Done column shows the newest 20 archived cards; a group's overall card now completes when its last child finishes or is cancelled (#1252).

‎core/client.js‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8854,6 +8854,10 @@ function shortId(id) {
88548854
const hex = String(id ?? "").replace(/^task-/, "");
88558855
return `#${hex.slice(-6)}`;
88568856
}
8857+
var ARCHIVE_SHOWN = 20;
8858+
function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) {
8859+
return [...tasks ?? [], ...(archive ?? []).slice(0, limit)];
8860+
}
88578861
function boardColumns(tasks) {
88588862
const columns = { "not-started": [], "in-progress": [], done: [] };
88598863
for (const task of tasks ?? []) {
@@ -8974,19 +8978,27 @@ function lanesOf(tasks, roster) {
89748978
}
89758979
return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) }));
89768980
}
8981+
var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task];
8982+
var without = (list, id) => list.filter((item) => item.id !== id);
89778983
function useBoardTasks(actions, scope) {
89788984
const [own, setOwn] = (0, import_react17.useState)([]);
89798985
const [groupTasks, setGroupTasks] = (0, import_react17.useState)([]);
8986+
const [archive, setArchive] = (0, import_react17.useState)([]);
89808987
const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own";
89818988
(0, import_react17.useEffect)(() => {
89828989
let stopped = false;
89838990
const load = () => {
8984-
const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.({ roomId: scope.roomId })) : Promise.resolve(actions?.tasks?.());
8991+
const query = scope?.kind === "group" ? { roomId: scope.roomId } : {};
8992+
const job = scope?.kind === "group" ? Promise.resolve(actions?.tasks?.(query)) : Promise.resolve(actions?.tasks?.());
89858993
void job.then((list) => {
89868994
if (stopped || !Array.isArray(list)) return;
89878995
if (scope?.kind === "group") setGroupTasks(list);
89888996
else setOwn(list);
89898997
}).catch(() => void 0);
8998+
void Promise.resolve(actions?.tasks?.({ ...query, archive: true })).then((list) => {
8999+
if (stopped || !Array.isArray(list)) return;
9000+
setArchive(list.filter((task) => columnOf(task) === "done"));
9001+
}).catch(() => void 0);
89909002
};
89919003
load();
89929004
const timer = setInterval(load, POLL_MS4);
@@ -8997,11 +9009,13 @@ function useBoardTasks(actions, scope) {
89979009
}, [key]);
89989010
const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId);
89999011
const replace = (task) => {
9000-
const write = (list) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task];
9001-
if (scope?.kind === "group") setGroupTasks(write);
9002-
else setOwn(write);
9012+
const done2 = columnOf(task) === "done";
9013+
if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task));
9014+
else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task));
9015+
setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id));
90039016
};
9004-
return { own, tasks, replace };
9017+
const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId);
9018+
return { own, tasks, archive: done, replace };
90059019
}
90069020
function RailButton({ row, selected, meta, onSelect }) {
90079021
return (0, import_react17.createElement)(
@@ -9210,11 +9224,12 @@ function KanbanDialog({ roster, actions, leaving = false }) {
92109224
const [dragging, setDragging] = (0, import_react17.useState)(null);
92119225
const [error, setError] = (0, import_react17.useState)("");
92129226
const [draft, setDraft] = (0, import_react17.useState)("");
9213-
const { own, tasks, replace } = useBoardTasks(actions, scope);
9227+
const { own, tasks, archive, replace } = useBoardTasks(actions, scope);
9228+
const boardTasks = boardTasksFor(tasks, archive);
92149229
const close = (0, import_react17.useCallback)(() => actions.closeBoard(), []);
92159230
useEscape(close);
92169231
const counts2 = {};
9217-
for (const task of own) {
9232+
for (const task of [...own, ...archive]) {
92189233
const id = task.ownerBotId;
92199234
const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 });
92209235
if (task.status === "cancelled" || task.status === "failed") continue;
@@ -9266,7 +9281,7 @@ function KanbanDialog({ roster, actions, leaving = false }) {
92669281
),
92679282
view === "delivery" ? (0, import_react17.createElement)(DeliveryView, { actions }) : (0, import_react17.createElement)(BoardView, {
92689283
roster,
9269-
tasks,
9284+
tasks: boardTasks,
92709285
rail,
92719286
scope,
92729287
counts: counts2,

‎core/src/client/kanban.js‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export function shortId(id) {
2424
return `#${hex.slice(-6)}`
2525
}
2626

27+
// #1252: the board lists active work; the Done column reads a bounded slice of the archive,
28+
// so a room with hundreds of finished cards still renders a column, not the whole history.
29+
export const ARCHIVE_SHOWN = 20
30+
31+
export function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) {
32+
return [...(tasks ?? []), ...(archive ?? []).slice(0, limit)]
33+
}
34+
2735
export function boardColumns(tasks) {
2836
const columns = { 'not-started': [], 'in-progress': [], done: [] }
2937
for (const task of tasks ?? []) {
@@ -150,34 +158,47 @@ function lanesOf(tasks, roster) {
150158
return order.map(id => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) }))
151159
}
152160

161+
const upsert = (list, task) => (list.some(item => item.id === task.id) ? list.map(item => item.id === task.id ? task : item) : [...list, task])
162+
const without = (list, id) => list.filter(item => item.id !== id)
163+
153164
export function useBoardTasks(actions, scope) {
154165
const [own, setOwn] = useState([])
155166
const [groupTasks, setGroupTasks] = useState([])
167+
const [archive, setArchive] = useState([])
156168
const key = scope?.kind === 'group' ? `group:${scope.roomId}` : 'own'
157169
useEffect(() => {
158170
let stopped = false
159171
const load = () => {
172+
const query = scope?.kind === 'group' ? { roomId: scope.roomId } : {}
173+
// #1252: the default list is active work, so the Done column asks for the archive.
160174
const job = scope?.kind === 'group'
161-
? Promise.resolve(actions?.tasks?.({ roomId: scope.roomId }))
175+
? Promise.resolve(actions?.tasks?.(query))
162176
: Promise.resolve(actions?.tasks?.())
163177
// A reading host or an older core has no board; keep the list already shown.
164178
void job.then((list) => {
165179
if (stopped || !Array.isArray(list)) return
166180
if (scope?.kind === 'group') setGroupTasks(list)
167181
else setOwn(list)
168182
}).catch(() => undefined)
183+
void Promise.resolve(actions?.tasks?.({ ...query, archive: true })).then((list) => {
184+
if (stopped || !Array.isArray(list)) return
185+
setArchive(list.filter(task => columnOf(task) === 'done'))
186+
}).catch(() => undefined)
169187
}
170188
load()
171189
const timer = setInterval(load, POLL_MS)
172190
return () => { stopped = true; clearInterval(timer) }
173191
}, [key])
174192
const tasks = scope?.kind === 'group' ? groupTasks : own.filter(task => !scope?.botId || task.ownerBotId === scope.botId)
193+
// A finished card moves off the active list into the archive immediately, before the next poll.
175194
const replace = (task) => {
176-
const write = (list) => (list.some(item => item.id === task.id) ? list.map(item => item.id === task.id ? task : item) : [...list, task])
177-
if (scope?.kind === 'group') setGroupTasks(write)
178-
else setOwn(write)
195+
const done = columnOf(task) === 'done'
196+
if (scope?.kind === 'group') setGroupTasks(list => done ? without(list, task.id) : upsert(list, task))
197+
else setOwn(list => done ? without(list, task.id) : upsert(list, task))
198+
setArchive(list => done ? [task, ...without(list, task.id)] : without(list, task.id))
179199
}
180-
return { own, tasks, replace }
200+
const done = scope?.kind === 'group' ? archive : archive.filter(task => !scope?.botId || task.ownerBotId === scope.botId)
201+
return { own, tasks, archive: done, replace }
181202
}
182203

183204
function RailButton({ row, selected, meta, onSelect }) {
@@ -338,11 +359,12 @@ export function KanbanDialog({ roster, actions, leaving = false }) {
338359
const [dragging, setDragging] = useState(null)
339360
const [error, setError] = useState('')
340361
const [draft, setDraft] = useState('')
341-
const { own, tasks, replace } = useBoardTasks(actions, scope)
362+
const { own, tasks, archive, replace } = useBoardTasks(actions, scope)
363+
const boardTasks = boardTasksFor(tasks, archive)
342364
const close = useCallback(() => actions.closeBoard(), [])
343365
useEscape(close)
344366
const counts = {}
345-
for (const task of own) {
367+
for (const task of [...own, ...archive]) {
346368
const id = task.ownerBotId
347369
const bucket = counts[id] ?? (counts[id] = { done: 0, total: 0 })
348370
if (task.status === 'cancelled' || task.status === 'failed') continue
@@ -385,7 +407,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) {
385407
view === 'delivery'
386408
? h(DeliveryView, { actions })
387409
: h(BoardView, {
388-
roster, tasks, rail, scope, counts, dragging, error, draft,
410+
roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft,
389411
onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create,
390412
})))
391413
}

‎core/src/host/tasks.js‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,23 @@ export function install(rt) {
292292
transition(task, next, actor, extra)
293293
if (next === 'result-ready') task.result = { kind: 'summary', summary: (task.note || 'Completed').slice(0, 2000) }
294294
}
295+
if (target === 'accepted') settleOverall(task)
295296
return true
296297
}
297298

299+
// #1252: a group whose last child finishes otherwise leaves its overall card working forever,
300+
// so the room board keeps a card nobody can close. Finishing or cancelling the last open child
301+
// completes the overall card, with the same write a person's own move makes.
302+
function settleOverall(child) {
303+
const parent = child?.parentId ? store()[child.parentId] : undefined
304+
if (parent === undefined || parent.kind !== 'group' || !openTask(parent)) return false
305+
if (!['open', 'claimed', 'working'].includes(parent.status)) return false
306+
if (Object.values(store()).some(task => task.parentId === parent.id && openTask(task))) return false
307+
const moved = applyColumn(parent, 'done', null, 'last child completed')
308+
if (moved) parent.progress = 100
309+
return moved
310+
}
311+
298312
function applyFields(task, body) {
299313
const title = body.title !== undefined ? readTitle(body.title) : undefined
300314
if (title !== undefined) {
@@ -764,7 +778,10 @@ export function install(rt) {
764778
// the room query, so a plain list never becomes a group-admin view.
765779
rows = all.filter(task => mayAct(task, actor) || ancestorOwned(actor, task.ownerBotId))
766780
}
767-
return rows.sort((a, b) => a.createdAt - b.createdAt).map(view)
781+
// #1252: the board lists active work; the archive is the same read with a flag, so a
782+
// group that has finished hundreds of cards does not ship them on every poll.
783+
const shown = body.archive === true ? rows : rows.filter(openTask)
784+
return shown.sort((a, b) => a.createdAt - b.createdAt).map(view)
768785
}
769786
case 'task-create': {
770787
const botId = String(body.botId ?? '')
@@ -866,6 +883,7 @@ export function install(rt) {
866883
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
867884
requireRequester(task, actor)
868885
transition(task, 'accepted', actor)
886+
settleOverall(task)
869887
requestWriteBack(task, 'accepted', undefined, actor)
870888
await save()
871889
return view(task)
@@ -888,6 +906,7 @@ export function install(rt) {
888906
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
889907
requireRequester(task, actor)
890908
transition(task, 'cancelled', actor)
909+
settleOverall(task)
891910
await save()
892911
return view(task)
893912
}

‎core/test/bot-tasks.test.js‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ test('a Bot records conversation tasks and the owner moves them across the board
138138
await first.stop({ keepHome: true })
139139
const restored = await start(t, { home: first.home })
140140
app = restored
141-
const saved = (await list(restored.call, alice.sessionToken, { botId: 's' })).find(task => task.id === open.id)
141+
const saved = (await list(restored.call, alice.sessionToken, { botId: 's', archive: true })).find(task => task.id === open.id)
142142
assert.equal(saved.status, 'accepted')
143143
assert.equal(saved.progress, 100)
144144
assert.equal(saved.priority, 'high')
@@ -434,3 +434,56 @@ test('the board asks the owning Bot for the comment a decision needs, and takes
434434
assert.equal(late.writeBack.wakes, 2, "the count stays with the record")
435435
assert.equal(late.status, "accepted")
436436
})
437+
438+
// #1252: a finished card leaves the active list, or a long-lived group's board grows without
439+
// bound; the archive is the same read with a flag, not a second endpoint.
440+
test('#1252: a room board lists active work only, and the archive is explicit', async (t) => {
441+
const app = await start(t)
442+
t.after(() => app.stop())
443+
const alice = await app.signIn('Alice')
444+
await app.track('a', { tasks: [
445+
{ title: 'Keep this moving', group: 'War', column: 'in-progress', progress: 30 },
446+
{ title: 'This one is done', group: 'War', column: 'in-progress', progress: 10 },
447+
] })
448+
const before = await list(app.call, alice.sessionToken, { roomId: 'war' })
449+
const finished = before.find(task => task.title === 'This one is done')
450+
const done = await app.call('task-update', { id: finished.id, column: 'done' }, alice.sessionToken)
451+
assert.equal(done.ok, true, JSON.stringify(done))
452+
453+
const active = await list(app.call, alice.sessionToken, { roomId: 'war' })
454+
assert.equal(active.some(task => task.id === finished.id), false, 'a done card leaves the room board by default')
455+
assert.equal(active.some(task => task.title === 'Keep this moving'), true, 'unfinished work stays')
456+
457+
const all = await list(app.call, alice.sessionToken, { roomId: 'war', archive: true })
458+
assert.equal(all.some(task => task.id === finished.id), true, 'the archive still carries the finished card')
459+
assert.equal(all.length, active.length + 1)
460+
})
461+
462+
// #1252: an overall card with no open child left has to be able to complete, or a group keeps a
463+
// card working that nobody can close.
464+
test('#1252: the last child completing completes the overall card instead of leaving it working', async (t) => {
465+
const app = await start(t)
466+
t.after(() => app.stop())
467+
const alice = await app.signIn('Alice')
468+
await app.track('a', { tasks: [
469+
{ title: 'Ship the note', overall: true, group: 'War' },
470+
{ title: 'Write it', parent: 'Ship the note', assignee: 'Writer', group: 'War' },
471+
{ title: 'Review it', parent: 'Ship the note', assignee: 'ChiefA', group: 'War' },
472+
] })
473+
const room = await list(app.call, alice.sessionToken, { roomId: 'war' })
474+
const overall = room.find(task => task.kind === 'group')
475+
const write = room.find(task => task.title === 'Write it')
476+
const review = room.find(task => task.title === 'Review it')
477+
478+
const firstMove = await app.call('task-update', { id: write.id, column: 'done' }, alice.sessionToken)
479+
assert.equal(firstMove.ok, true, JSON.stringify(firstMove))
480+
const afterOne = (await list(app.call, alice.sessionToken, { roomId: 'war' })).find(task => task.id === overall.id)
481+
assert.equal(afterOne.status, 'open', 'one child still open keeps the overall card where it was')
482+
483+
const lastMove = await app.call('task-update', { id: review.id, column: 'done' }, alice.sessionToken)
484+
assert.equal(lastMove.ok, true, JSON.stringify(lastMove))
485+
const parent = (await list(app.call, alice.sessionToken, { roomId: 'war', archive: true })).find(task => task.id === overall.id)
486+
assert.equal(parent.status, 'accepted', 'the overall card completes with its last child')
487+
assert.equal(columnOf(parent), 'done')
488+
assert.equal(parent.progress, 100)
489+
})

‎core/test/kanban.test.js‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ runInNewContext(bundle.outputFiles[0].text, {
2929
return platform[name]
3030
},
3131
})
32-
const { BoardView, KanbanDialog, boardColumns, boardRail, canMoveTask, columnTree, shortId } = module.exports
32+
const { ARCHIVE_SHOWN, BoardView, KanbanDialog, boardColumns, boardTasksFor, boardRail, canMoveTask, columnTree, shortId } = module.exports
3333

3434
const expand = (node) => {
3535
if (Array.isArray(node)) return node.flatMap(expand)
@@ -193,3 +193,18 @@ test('the Delivery view drops the Bot rail, while Tasks keeps it (#1173)', () =>
193193
// No snapshot yet is the honest one-line empty state until the first read lands.
194194
assert.match(textOf(deliveryView), /No snapshot yet/)
195195
})
196+
197+
// #1252: the Done column is archive, not the board. A room with hundreds of finished cards
198+
// still renders one bounded slice of it.
199+
test('#1252: a group board renders a bounded archive instead of every finished card', () => {
200+
const active = [{ id: 'task-live', ownerBotId: 'a', title: 'Live', status: 'working', updatedAt: 100 }]
201+
const archive = Array.from({ length: 37 }, (_, i) => ({
202+
id: `task-done-${i}`, ownerBotId: 'a', title: `Done ${i}`, status: 'accepted', progress: 100, updatedAt: i,
203+
}))
204+
const merged = boardTasksFor(active, archive)
205+
assert.equal(merged.length, 1 + ARCHIVE_SHOWN)
206+
const columns = boardColumns(merged)
207+
assert.equal(columns['in-progress'].length, 1)
208+
assert.equal(columns.done.length, ARCHIVE_SHOWN)
209+
assert.equal(columns.done[0].id, 'task-done-19', 'the column reads newest first within the slice')
210+
})

‎core/test/tasks.test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async function start(t, config = {}) {
9191
}
9292

9393
const statusOf = async (call, token, id) => {
94-
const list = await call('tasks', {}, token)
94+
const list = await call('tasks', { archive: true }, token)
9595
assert.equal(list.ok, true, JSON.stringify(list))
9696
return list.value.find(task => task.id === id)
9797
}

0 commit comments

Comments
 (0)