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:
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
"@runfusion/fusion": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Add a Todo aging indicator on the board. The Todo column header now shows
|
|
||||||
per-bucket counts (0–7d, 8–30d, 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.
|
|
||||||
8
.changeset/fn-4415-revert-todo-aging-indicator.md
Normal file
8
.changeset/fn-4415-revert-todo-aging-indicator.md
Normal 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.
|
||||||
@@ -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:
|
|
||||||
- `0–7d` (fresh)
|
|
||||||
- `8–30d` (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
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: "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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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("0–7d1");
|
|
||||||
expect(screen.getByTestId("todo-aging-chip-aging")).toHaveTextContent("8–30d1");
|
|
||||||
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({
|
||||||
|
|||||||
@@ -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("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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user