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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user