feat(FN-4316): complete Step 1 — add todo aging utility
Fusion-Task-Id: FN-4316 Fusion-Task-Lineage: 6b2b78b4-effa-479c-9a22-5a53d0f74894
This commit is contained in:
102
packages/dashboard/app/utils/__tests__/todoAging.test.ts
Normal file
102
packages/dashboard/app/utils/__tests__/todoAging.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import {
|
||||
TODO_AGING_THRESHOLDS_MS,
|
||||
getTodoAgeBucket,
|
||||
getTodoAgeMs,
|
||||
summarizeTodoAging,
|
||||
} from "../todoAging";
|
||||
|
||||
const createTask = (overrides: Partial<Task> = {}): Task =>
|
||||
({
|
||||
id: "FN-001",
|
||||
description: "Test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as Task;
|
||||
|
||||
describe("todoAging", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ageMs: 0, expected: "fresh" },
|
||||
{ ageMs: TODO_AGING_THRESHOLDS_MS.aging - 1, expected: "fresh" },
|
||||
{ ageMs: TODO_AGING_THRESHOLDS_MS.aging, expected: "fresh" },
|
||||
{ ageMs: TODO_AGING_THRESHOLDS_MS.aging + 1, expected: "aging" },
|
||||
{ ageMs: TODO_AGING_THRESHOLDS_MS.stale, expected: "aging" },
|
||||
{ ageMs: TODO_AGING_THRESHOLDS_MS.stale + 1, expected: "stale" },
|
||||
{ ageMs: 90 * 24 * 60 * 60 * 1000, expected: "stale" },
|
||||
])("maps age $ageMs to $expected", ({ ageMs, expected }) => {
|
||||
const task = createTask({ columnMovedAt: new Date(Date.now() - ageMs).toISOString() });
|
||||
expect(getTodoAgeBucket(task)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns undefined for non-todo tasks", () => {
|
||||
expect(getTodoAgeBucket(createTask({ column: "in-progress" }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back from columnMovedAt to createdAt to updatedAt", () => {
|
||||
const createdFallback = createTask({
|
||||
columnMovedAt: undefined,
|
||||
createdAt: new Date(Date.now() - (TODO_AGING_THRESHOLDS_MS.aging + 1)).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 1000).toISOString(),
|
||||
});
|
||||
expect(getTodoAgeBucket(createdFallback)).toBe("aging");
|
||||
|
||||
const updatedFallback = createTask({
|
||||
columnMovedAt: undefined,
|
||||
createdAt: "",
|
||||
updatedAt: new Date(Date.now() - (TODO_AGING_THRESHOLDS_MS.stale + 1)).toISOString(),
|
||||
});
|
||||
expect(getTodoAgeBucket(updatedFallback)).toBe("stale");
|
||||
|
||||
const missingAll = createTask({ columnMovedAt: undefined, createdAt: "", updatedAt: "" });
|
||||
expect(getTodoAgeBucket(missingAll)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summarizeTodoAging counts only todo tasks and returns total", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-1", columnMovedAt: new Date(Date.now() - 1000).toISOString() }),
|
||||
createTask({ id: "FN-2", columnMovedAt: new Date(Date.now() - (TODO_AGING_THRESHOLDS_MS.aging + 1)).toISOString() }),
|
||||
createTask({ id: "FN-3", columnMovedAt: new Date(Date.now() - (TODO_AGING_THRESHOLDS_MS.stale + 1)).toISOString() }),
|
||||
createTask({ id: "FN-4", column: "done" }),
|
||||
];
|
||||
|
||||
expect(summarizeTodoAging(tasks)).toEqual({
|
||||
fresh: 1,
|
||||
aging: 1,
|
||||
stale: 1,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses dataAsOfMs for freshness-aware age calculation", () => {
|
||||
vi.setSystemTime(new Date(1000));
|
||||
const task = createTask({ columnMovedAt: new Date(-8 * 24 * 60 * 60 * 1000).toISOString() });
|
||||
const dataAsOfMs = -2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
expect(getTodoAgeMs(task, dataAsOfMs)).toBe(6 * 24 * 60 * 60 * 1000);
|
||||
expect(getTodoAgeBucket(task, dataAsOfMs)).toBe("fresh");
|
||||
expect(getTodoAgeBucket(task)).toBe("aging");
|
||||
});
|
||||
|
||||
it("returns undefined for malformed timestamps", () => {
|
||||
const task = createTask({ columnMovedAt: "not-a-date", createdAt: "also-not-a-date", updatedAt: "" });
|
||||
expect(getTodoAgeMs(task)).toBeUndefined();
|
||||
expect(getTodoAgeBucket(task)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
81
packages/dashboard/app/utils/todoAging.ts
Normal file
81
packages/dashboard/app/utils/todoAging.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
export type TodoAgeBucket = "fresh" | "aging" | "stale";
|
||||
|
||||
export interface TodoAgingCounts {
|
||||
fresh: number;
|
||||
aging: number;
|
||||
stale: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const TODO_AGING_THRESHOLDS_MS = {
|
||||
aging: 7 * 24 * 60 * 60 * 1000,
|
||||
stale: 30 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Derive todo age in milliseconds using timestamp precedence:
|
||||
* 1) `columnMovedAt` (when task entered todo)
|
||||
* 2) `createdAt` (legacy fallback)
|
||||
* 3) `updatedAt` (last-resort fallback)
|
||||
*
|
||||
* The optional `dataAsOfMs` represents when task data was last confirmed
|
||||
* fresh by the server. When provided, it is used instead of `Date.now()` to
|
||||
* avoid false aging when the tab has been backgrounded.
|
||||
*/
|
||||
export function getTodoAgeMs(task: Task, dataAsOfMs?: number): number | undefined {
|
||||
if (task.column !== "todo") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timestamp = task.columnMovedAt || task.createdAt || task.updatedAt;
|
||||
if (!timestamp) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const referenceMs = new Date(timestamp).getTime();
|
||||
if (!Number.isFinite(referenceMs)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const now = dataAsOfMs ?? Date.now();
|
||||
return now - referenceMs;
|
||||
}
|
||||
|
||||
export function getTodoAgeBucket(task: Task, dataAsOfMs?: number): TodoAgeBucket | undefined {
|
||||
const ageMs = getTodoAgeMs(task, dataAsOfMs);
|
||||
if (ageMs === undefined || ageMs < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (ageMs <= TODO_AGING_THRESHOLDS_MS.aging) {
|
||||
return "fresh";
|
||||
}
|
||||
|
||||
if (ageMs <= TODO_AGING_THRESHOLDS_MS.stale) {
|
||||
return "aging";
|
||||
}
|
||||
|
||||
return "stale";
|
||||
}
|
||||
|
||||
export function summarizeTodoAging(tasks: Task[], dataAsOfMs?: number): TodoAgingCounts {
|
||||
const counts: TodoAgingCounts = {
|
||||
fresh: 0,
|
||||
aging: 0,
|
||||
stale: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
for (const task of tasks) {
|
||||
const bucket = getTodoAgeBucket(task, dataAsOfMs);
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
counts[bucket] += 1;
|
||||
counts.total += 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
Reference in New Issue
Block a user