feat(FN-4316): complete Step 2 — add todo aging summary component

Fusion-Task-Id: FN-4316
Fusion-Task-Lineage: 6b2b78b4-effa-479c-9a22-5a53d0f74894
This commit is contained in:
Fusion
2026-05-13 17:51:19 -07:00
committed by gsxdsm
parent 32e616c6bd
commit f7ba4c79f9
3 changed files with 159 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
.todo-aging-summary {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.todo-aging-chip {
align-items: center;
border-radius: var(--radius-pill);
display: inline-flex;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
transition: box-shadow var(--transition-fast), border-color var(--transition-fast), background-color var(--transition-fast);
}
.todo-aging-chip-count {
font-family: var(--font-mono);
}
.todo-aging-chip--fresh {
background: var(--surface);
}
.todo-aging-chip--aging {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
}
.todo-aging-chip--stale {
background: color-mix(in srgb, var(--color-warning) 20%, transparent);
}
.todo-aging-chip--active {
box-shadow: var(--focus-ring-strong);
}
@media (max-width: 768px) {
.todo-aging-chip {
padding: var(--space-xs);
}
}

View File

@@ -0,0 +1,46 @@
import type { Task } from "@fusion/core";
import type { TodoAgeBucket } from "../utils/todoAging";
import { summarizeTodoAging } from "../utils/todoAging";
import "./TodoAgingSummary.css";
interface TodoAgingSummaryProps {
tasks: Task[];
activeBucket: TodoAgeBucket | null;
onSelectBucket: (bucket: TodoAgeBucket | null) => void;
dataAsOfMs?: number;
}
const BUCKETS: Array<{ bucket: TodoAgeBucket; label: string; title: string }> = [
{ bucket: "fresh", label: "07d", title: "Todo tasks 07 days old" },
{ bucket: "aging", label: "830d", title: "Todo tasks 830 days old" },
{ bucket: "stale", label: "31+d", title: "Todo tasks 31+ days old" },
];
export function TodoAgingSummary({ tasks, activeBucket, onSelectBucket, dataAsOfMs }: TodoAgingSummaryProps) {
const counts = summarizeTodoAging(tasks, dataAsOfMs);
if (counts.total === 0) {
return null;
}
return (
<div className="todo-aging-summary" data-testid="todo-aging-summary">
{BUCKETS.map(({ bucket, label, title }) => {
const isActive = activeBucket === bucket;
return (
<button
key={bucket}
type="button"
className={`btn btn-sm todo-aging-chip todo-aging-chip--${bucket}${isActive ? " todo-aging-chip--active" : ""}`}
aria-pressed={isActive}
title={title}
onClick={() => onSelectBucket(isActive ? null : bucket)}
data-testid={`todo-aging-chip-${bucket}`}
>
<span>{label}</span>
<span className="todo-aging-chip-count">{counts[bucket]}</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { TodoAgingSummary } from "../TodoAgingSummary";
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("TodoAgingSummary", () => {
it("renders three chips with bucket counts", () => {
const now = new Date("2026-04-04T12:00:00Z").getTime();
const tasks = [
createTask({ id: "FN-1", columnMovedAt: new Date(now - 1000).toISOString() }),
createTask({ id: "FN-2", columnMovedAt: new Date(now - 8 * 24 * 60 * 60 * 1000).toISOString() }),
createTask({ id: "FN-3", columnMovedAt: new Date(now - 40 * 24 * 60 * 60 * 1000).toISOString() }),
];
render(<TodoAgingSummary tasks={tasks} activeBucket={null} onSelectBucket={() => undefined} dataAsOfMs={now} />);
expect(screen.getByTestId("todo-aging-chip-fresh")).toHaveTextContent("07d1");
expect(screen.getByTestId("todo-aging-chip-aging")).toHaveTextContent("830d1");
expect(screen.getByTestId("todo-aging-chip-stale")).toHaveTextContent("31+d1");
});
it("returns null when there are zero todo tasks", () => {
const { container } = render(
<TodoAgingSummary
tasks={[createTask({ column: "done" })]}
activeBucket={null}
onSelectBucket={() => undefined}
/>,
);
expect(container.firstChild).toBeNull();
});
it("clicking chips toggles bucket selection", () => {
const onSelectBucket = vi.fn();
const now = Date.now();
const tasks = [createTask({ columnMovedAt: new Date(now - 35 * 24 * 60 * 60 * 1000).toISOString() })];
render(<TodoAgingSummary tasks={tasks} activeBucket={"stale"} onSelectBucket={onSelectBucket} dataAsOfMs={now} />);
fireEvent.click(screen.getByTestId("todo-aging-chip-aging"));
expect(onSelectBucket).toHaveBeenCalledWith("aging");
fireEvent.click(screen.getByTestId("todo-aging-chip-stale"));
expect(onSelectBucket).toHaveBeenCalledWith(null);
});
it("sets aria-pressed based on active bucket", () => {
const now = Date.now();
const tasks = [createTask({ columnMovedAt: new Date(now - 35 * 24 * 60 * 60 * 1000).toISOString() })];
render(<TodoAgingSummary tasks={tasks} activeBucket={"stale"} onSelectBucket={() => undefined} dataAsOfMs={now} />);
expect(screen.getByTestId("todo-aging-chip-fresh")).toHaveAttribute("aria-pressed", "false");
expect(screen.getByTestId("todo-aging-chip-aging")).toHaveAttribute("aria-pressed", "false");
expect(screen.getByTestId("todo-aging-chip-stale")).toHaveAttribute("aria-pressed", "true");
});
});