feat(FN-4415): revert todo aging indicator from FN-4316

Reverts the FN-4316 todo aging indicator feature, removing the `TodoAgingSummary` component and its CSS, the `todoAging` utility, all associated tests, and the corresponding documentation. The revert is accompanied by a changeset confirming the patch-level reversal.

Fusion-Task-Id: FN-4415
This commit is contained in:
Fusion
2026-05-13 19:18:58 -07:00
committed by gsxdsm
parent ab4df2a72f
commit f790436c95
10 changed files with 15 additions and 488 deletions

View File

@@ -1,8 +0,0 @@
---
"@runfusion/fusion": minor
---
Add a Todo aging indicator on the board. The Todo column header now shows
per-bucket counts (07d, 830d, 31+d) derived from `columnMovedAt`
(falling back to `createdAt`, then `updatedAt`). Clicking a bucket filters
the Todo column to that bucket; clicking it again clears the filter.

View File

@@ -0,0 +1,8 @@
---
"@runfusion/fusion": patch
---
Revert the Todo aging indicator added in FN-4316. The Todo column header
no longer shows age-bucket counts or supports click-to-filter by bucket;
Column rendering and pagination behave exactly as they did before
FN-4316.

View File

@@ -133,20 +133,6 @@ Board ordering behavior:
- The `done` column is recency-ordered by completion time (newest first), using `columnMovedAt` as primary and falling back to `updatedAt` then `createdAt` for legacy tasks. - The `done` column is recency-ordered by completion time (newest first), using `columnMovedAt` as primary and falling back to `updatedAt` then `createdAt` for legacy tasks.
- The dashboard **list view default ordering matches these same per-column semantics** until a user clicks a sortable header (manual list sorting still overrides defaults). - The dashboard **list view default ordering matches these same per-column semantics** until a user clicks a sortable header (manual list sorting still overrides defaults).
#### Todo aging indicator
The board Todo column header shows a client-side aging summary for Todo tasks:
- `07d` (fresh)
- `830d` (aging)
- `31+d` (stale)
Age uses this timestamp precedence per task:
1. `columnMovedAt` (preferred; when the task entered Todo)
2. `createdAt` (fallback for legacy rows)
3. `updatedAt` (last resort)
Clicking a bucket filters only the Todo column to that bucket. Clicking the active bucket again clears the filter. The filter is local UI state (not persisted) and resets when navigating away/remounting the board.
### Lifecycle commands ### Lifecycle commands
```bash ```bash

View File

@@ -12,9 +12,6 @@ import type { ToastType } from "../hooks/useToast";
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react"; import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
import type { ModelInfo } from "../api"; import type { ModelInfo } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
import { TodoAgingSummary } from "./TodoAgingSummary";
import type { TodoAgeBucket } from "../utils/todoAging";
import { getTodoAgeBucket } from "../utils/todoAging";
const PAGINATED_COLUMN_THRESHOLD = 100; const PAGINATED_COLUMN_THRESHOLD = 100;
const VISIBLE_TASKS_INITIAL = 50; const VISIBLE_TASKS_INITIAL = 50;
@@ -81,7 +78,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const [isReplanning, setIsReplanning] = useState(false); const [isReplanning, setIsReplanning] = useState(false);
const [isPausingAll, setIsPausingAll] = useState(false); const [isPausingAll, setIsPausingAll] = useState(false);
const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false); const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false);
const [agingBucket, setAgingBucket] = useState<TodoAgeBucket | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
const countFlashing = useFlashOnIncrease(tasks.length); const countFlashing = useFlashOnIncrease(tasks.length);
const { confirm } = useConfirm(); const { confirm } = useConfirm();
@@ -189,19 +185,12 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
return groupByWorktree(tasks, tasks, maxConcurrent); return groupByWorktree(tasks, tasks, maxConcurrent);
}, [column, tasks, maxConcurrent]); }, [column, tasks, maxConcurrent]);
const filteredTasks = useMemo(() => {
if (column !== "todo" || !agingBucket) {
return tasks;
}
return tasks.filter((task) => getTodoAgeBucket(task, lastFetchTimeMs) === agingBucket);
}, [agingBucket, column, lastFetchTimeMs, tasks]);
const visibleTasks = useMemo(() => { const visibleTasks = useMemo(() => {
if (!shouldPaginate) return filteredTasks; if (!shouldPaginate) return tasks;
return filteredTasks.slice(0, visibleTaskCount); return tasks.slice(0, visibleTaskCount);
}, [filteredTasks, shouldPaginate, visibleTaskCount]); }, [shouldPaginate, tasks, visibleTaskCount]);
const hiddenTaskCount = Math.max(0, filteredTasks.length - visibleTasks.length); const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length);
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length)); setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
@@ -356,18 +345,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
<div className="column-header"> <div className="column-header">
<div className={`column-dot dot-${column}`} /> <div className={`column-dot dot-${column}`} />
<h2>{COLUMN_LABELS[column]}</h2> <h2>{COLUMN_LABELS[column]}</h2>
<span className={`column-count${countFlashing ? " count-flash" : ""}`}> <span className={`column-count${countFlashing ? " count-flash" : ""}`}>{tasks.length}</span>
{tasks.length}
{column === "todo" && agingBucket && <span>{` ${filteredTasks.length} / ${tasks.length}`}</span>}
</span>
{column === "todo" && (
<TodoAgingSummary
tasks={tasks}
activeBucket={agingBucket}
onSelectBucket={setAgingBucket}
dataAsOfMs={lastFetchTimeMs}
/>
)}
{column === "in-review" && onToggleAutoMerge && ( {column === "in-review" && onToggleAutoMerge && (
<label className="auto-merge-toggle" title={autoMerge ? "Auto-merge enabled" : "Auto-merge disabled"}> <label className="auto-merge-toggle" title={autoMerge ? "Auto-merge enabled" : "Auto-merge disabled"}>
<input <input
@@ -517,19 +495,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
/> />
)) ))
) )
) : filteredTasks.length === 0 ? ( ) : tasks.length === 0 ? (
agingBucket ? ( <div className="empty-column">No tasks</div>
<div className="empty-column">
No tasks in this bucket
<div>
<button type="button" className="btn btn-sm" onClick={() => setAgingBucket(null)}>
Clear filter
</button>
</div>
</div>
) : (
<div className="empty-column">No tasks</div>
)
) : ( ) : (
<> <>
{visibleTasks.map((task) => ( {visibleTasks.map((task) => (

View File

@@ -1,40 +0,0 @@
.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

@@ -1,46 +0,0 @@
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

@@ -536,90 +536,6 @@ describe("Column same-column drop", () => {
}); });
}); });
describe("Column todo aging summary", () => {
const now = new Date("2026-04-04T12:00:00Z").getTime();
const makeTodoTask = (id: string, ageDays: number): Task => ({
...makeTask(id),
column: "todo",
columnMovedAt: new Date(now - ageDays * 24 * 60 * 60 * 1000).toISOString(),
createdAt: new Date(now - ageDays * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now - ageDays * 24 * 60 * 60 * 1000).toISOString(),
});
it.each(["triage", "in-progress", "in-review", "done", "archived"] as const)(
"renders aging summary only on todo column (not %s)",
(column) => {
render(<Column {...defaultProps} column={column} tasks={[{ ...makeTask("FN-001"), column }]} lastFetchTimeMs={now} />);
expect(screen.queryByTestId("todo-aging-summary")).toBeNull();
},
);
it("renders aging summary counts for todo bucket totals", () => {
render(
<Column
{...defaultProps}
column="todo"
tasks={[makeTodoTask("FN-001", 2), makeTodoTask("FN-002", 10), makeTodoTask("FN-003", 45)]}
lastFetchTimeMs={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("filters todo tasks by selected bucket and toggles off on second click", async () => {
const user = userEvent.setup();
render(
<Column
{...defaultProps}
column="todo"
tasks={[makeTodoTask("FN-001", 2), makeTodoTask("FN-002", 10), makeTodoTask("FN-003", 45)]}
lastFetchTimeMs={now}
/>,
);
await user.click(screen.getByTestId("todo-aging-chip-stale"));
expect(screen.queryByTestId("task-FN-001")).toBeNull();
expect(screen.queryByTestId("task-FN-002")).toBeNull();
expect(screen.getByTestId("task-FN-003")).toBeTruthy();
const columnCount = screen.getByText((_content, node) => {
if (!node || !node.classList.contains("column-count")) {
return false;
}
return node.textContent?.includes("1 / 3") ?? false;
});
expect(columnCount).toBeTruthy();
await user.click(screen.getByTestId("todo-aging-chip-stale"));
expect(screen.getByTestId("task-FN-001")).toBeTruthy();
expect(screen.getByTestId("task-FN-002")).toBeTruthy();
expect(screen.getByTestId("task-FN-003")).toBeTruthy();
});
it("renders empty bucket hint and clear filter action when selected bucket has no tasks", async () => {
const user = userEvent.setup();
render(
<Column
{...defaultProps}
column="todo"
tasks={[makeTodoTask("FN-001", 2), makeTodoTask("FN-002", 8)]}
lastFetchTimeMs={now}
/>,
);
await user.click(screen.getByTestId("todo-aging-chip-stale"));
expect(screen.getByText("No tasks in this bucket")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "Clear filter" }));
expect(screen.queryByText("No tasks in this bucket")).toBeNull();
expect(screen.getByTestId("task-FN-001")).toBeTruthy();
expect(screen.getByTestId("task-FN-002")).toBeTruthy();
});
});
describe("Column PluginSlot integration", () => { describe("Column PluginSlot integration", () => {
it("renders PluginSlot for board-column-footer", () => { it("renders PluginSlot for board-column-footer", () => {
mockUsePluginUiSlots.mockReturnValue({ mockUsePluginUiSlots.mockReturnValue({

View File

@@ -1,73 +0,0 @@
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");
});
});

View File

@@ -1,102 +0,0 @@
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

@@ -1,81 +0,0 @@
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;
}