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:
Fusion
2026-05-13 17:47:09 -07:00
committed by gsxdsm
parent f2b933bf22
commit 32e616c6bd
2 changed files with 183 additions and 0 deletions

View 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();
});
});

View 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;
}