feat(HAI-100): add columnMovedAt timestamp and sort board tasks by move order

- Add columnMovedAt field to task types
- Set columnMovedAt on task creation and column moves in store
- Sort tasks by columnMovedAt descending in Board component
- Add store tests for columnMovedAt behavior
This commit is contained in:
Dustin Byrne
2026-03-26 20:12:30 -04:00
parent a8b767d6fc
commit 57e380bb0f
4 changed files with 58 additions and 3 deletions

View File

@@ -611,4 +611,42 @@ describe("TaskStore", () => {
expect(logs[4].text).toBe("chunk 4"); expect(logs[4].text).toBe("chunk 4");
}); });
}); });
describe("columnMovedAt", () => {
it("createTask sets columnMovedAt", async () => {
const before = new Date().toISOString();
const task = await store.createTask({ description: "test columnMovedAt" });
const after = new Date().toISOString();
expect(task.columnMovedAt).toBeDefined();
expect(task.columnMovedAt! >= before).toBe(true);
expect(task.columnMovedAt! <= after).toBe(true);
});
it("moveTask sets columnMovedAt to a recent ISO timestamp", async () => {
const task = await store.createTask({ description: "move test", column: "triage" });
const originalMovedAt = task.columnMovedAt;
// Small delay to ensure timestamp differs
await new Promise((r) => setTimeout(r, 10));
const before = new Date().toISOString();
const moved = await store.moveTask(task.id, "todo");
const after = new Date().toISOString();
expect(moved.columnMovedAt).toBeDefined();
expect(moved.columnMovedAt! >= before).toBe(true);
expect(moved.columnMovedAt! <= after).toBe(true);
expect(moved.columnMovedAt).not.toBe(originalMovedAt);
});
it("updateTask does NOT change columnMovedAt", async () => {
const task = await store.createTask({ description: "no change test" });
const originalMovedAt = task.columnMovedAt;
await new Promise((r) => setTimeout(r, 10));
const updated = await store.updateTask(task.id, { title: "new title" });
expect(updated.columnMovedAt).toBe(originalMovedAt);
});
});
}); });

View File

@@ -188,6 +188,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
steps: [], steps: [],
currentStep: 0, currentStep: 0,
log: [{ timestamp: now, action: "Task created" }], log: [{ timestamp: now, action: "Task created" }],
columnMovedAt: now,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -263,7 +264,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const fromColumn = task.column; const fromColumn = task.column;
task.column = toColumn; task.column = toColumn;
task.updatedAt = new Date().toISOString(); task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
// Clear transient fields when moving to done (matches moveToDone behavior) // Clear transient fields when moving to done (matches moveToDone behavior)
if (toColumn === "done") { if (toColumn === "done") {
@@ -605,7 +607,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.worktree = undefined; task.worktree = undefined;
task.status = undefined; task.status = undefined;
task.blockedBy = undefined; task.blockedBy = undefined;
task.updatedAt = new Date().toISOString(); task.columnMovedAt = new Date().toISOString();
task.updatedAt = task.columnMovedAt;
await this.atomicWriteTaskJson(dir, task); await this.atomicWriteTaskJson(dir, task);

View File

@@ -57,6 +57,9 @@ export interface Task {
log: TaskLogEntry[]; log: TaskLogEntry[];
size?: "S" | "M" | "L"; size?: "S" | "M" | "L";
reviewLevel?: number; reviewLevel?: number;
/** ISO-8601 timestamp of when the task last entered its current column.
* Used to sort cards within a column so that recently-moved cards appear at the top. */
columnMovedAt?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -24,7 +24,18 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
<Column <Column
key={col} key={col}
column={col} column={col}
tasks={tasks.filter((t) => t.column === col)} tasks={tasks
.filter((t) => t.column === col)
.sort((a, b) => {
// Tasks with columnMovedAt sort descending (most recent first)
// Tasks without it (legacy) fall to the bottom, sorted by createdAt ascending
if (a.columnMovedAt && b.columnMovedAt) {
return b.columnMovedAt.localeCompare(a.columnMovedAt);
}
if (a.columnMovedAt && !b.columnMovedAt) return -1;
if (!a.columnMovedAt && b.columnMovedAt) return 1;
return a.createdAt.localeCompare(b.createdAt);
})}
allTasks={tasks} allTasks={tasks}
maxConcurrent={maxConcurrent} maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask} onMoveTask={onMoveTask}