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:
40
packages/dashboard/app/components/TodoAgingSummary.css
Normal file
40
packages/dashboard/app/components/TodoAgingSummary.css
Normal 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);
|
||||
}
|
||||
}
|
||||
46
packages/dashboard/app/components/TodoAgingSummary.tsx
Normal file
46
packages/dashboard/app/components/TodoAgingSummary.tsx
Normal 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: "0–7d", title: "Todo tasks 0–7 days old" },
|
||||
{ bucket: "aging", label: "8–30d", title: "Todo tasks 8–30 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>
|
||||
);
|
||||
}
|
||||
@@ -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("0–7d1");
|
||||
expect(screen.getByTestId("todo-aging-chip-aging")).toHaveTextContent("8–30d1");
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user