feat(FN-822): add visible stuck indicators on board and list with unified detection

- Unify stuck-task derivation using project settings (taskStuckTimeoutMs) instead of hardcoded values
- Thread taskStuckTimeoutMs through Board, Column, WorktreeGroup, and TaskCard components
- Add visual stuck indicator with pulsing animation on in-progress task cards
- Resolve merge conflicts with isSearchActive prop from main (both props coexist)
- Add taskStuck utility unit tests and update README with stuck indicator docs
This commit is contained in:
gsxdsm
2026-04-04 11:31:22 -07:00
parent 9cf5528bc8
commit 039633ab9e
11 changed files with 318 additions and 16 deletions

View File

@@ -74,7 +74,7 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
**Statistics Displayed**:
- **Running**: Count of tasks currently in "in-progress" column with pulsing animation when > 0
- **Blocked**: Count of tasks with `blockedBy` field set (a single task ID string indicating file-overlap blocking)
- **Stuck**: Count of tasks in "in-progress" with no activity for > 10 minutes (shown only when > 0)
- **Stuck**: Count of tasks in "in-progress" with no activity for longer than the project's `taskStuckTimeoutMs` setting (shown only when > 0 and the setting is enabled). Uses the same `isTaskStuck()` predicate as task cards and list rows, so the footer count always matches the visible stuck indicators on the board
- **Queued**: Count of tasks in "todo" column
- **In Review**: Count of tasks in "in-review" column
- **Executor State**: Current state badge (Idle/Running/Paused)
@@ -86,7 +86,7 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
- **Running**: Engine active with running tasks
**Features**:
- **Shared task list**: Task counts are derived from the same task list used by the board and list views, so the footer always matches the board state exactly
- **Shared task list**: Task counts are derived from the same task list used by the board and list views, so the footer always matches the board state exactly. Stuck task detection uses a shared `isTaskStuck()` utility (see `utils/taskStuck.ts`) so the footer count and individual card/row indicators are always consistent
- **Footer-safe layout**: Project-view content (board, list view, agents view) automatically reserves space for the fixed footer using a CSS custom property (`--executor-footer-height`). The `project-content--with-footer` wrapper class sets this token to 36px on desktop and 32px on mobile, ensuring all content remains fully visible and scrollable above the status bar
- Real-time updates via 5-second polling for executor state (globalPause, enginePaused, maxConcurrent)
- Responsive design: collapses labels on mobile screens (<768px); footer height reduces from 36px to 32px
@@ -97,6 +97,12 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
**API Endpoint**:
- `GET /api/executor/stats` - Returns `globalPause`, `enginePaused`, `maxConcurrent`, and `lastActivityAt` for state derivation. Column-based counts (running, blocked, stuck, queued, in-review) are derived client-side from the shared task list.
**Stuck Task Indicators**:
When `taskStuckTimeoutMs` is configured in project settings, stuck tasks are visually labeled on both board cards and list rows using the same `isTaskStuck()` predicate as the footer count:
- **Board cards**: A pulsing amber "Stuck" badge replaces the normal status badge, and the card gets a left border highlight
- **List rows**: A "Stuck" label appears in the status cell, and the row gets a left border highlight
- **Consistency**: The footer stuck count, card stuck badge, and list stuck label all use the identical `isTaskStuck(task, taskStuckTimeoutMs)` check from `utils/taskStuck.ts`, so the footer count is always explainable by counting visible stuck indicators
### Agents View
Manage AI agents with a dedicated control surface accessible from the main dashboard navigation. All agent surfaces (AgentsView, AgentListModal, AgentDetailView) share consistent token-based styling using dashboard design tokens (`--surface`, `--card`, `--border`, `--text`, `--color-success`, `--color-error`, etc.) and locally defined state color tokens (`--state-idle-*`, `--state-active-*`, `--state-paused-*`, `--state-error-*`) for theme-aware rendering.

View File

@@ -561,6 +561,7 @@ function AppInner() {
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
/>
);
}
@@ -583,6 +584,7 @@ function AppInner() {
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
/>
);
};

View File

@@ -40,6 +40,8 @@ interface BoardProps {
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
}
function sortTasksForColumn(tasks: Task[]): Task[] {
@@ -58,7 +60,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const { fetchBatch } = useBatchBadgeFetch(projectId);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -175,6 +177,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}

View File

@@ -53,9 +53,11 @@ interface ColumnProps {
onToggleModelFavorite?: (modelId: string) => void;
/** When true, search is active — bypass pagination so all matching tasks are visible. */
isSearchActive?: boolean;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -225,6 +227,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onOpenFilesForTask={onOpenFilesForTask}
taskStuckTimeoutMs={taskStuckTimeoutMs}
/>
))
)
@@ -244,6 +247,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onOpenFilesForTask={onOpenFilesForTask}
taskStuckTimeoutMs={taskStuckTimeoutMs}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (

View File

@@ -6,6 +6,7 @@ import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
import type { ModelInfo } from "../api";
import { QuickEntryBox } from "./QuickEntryBox";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { isTaskStuck } from "../utils/taskStuck";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -56,6 +57,8 @@ interface ListViewProps {
projectId?: string;
/** Project name for display (optional) */
projectName?: string;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
}
function getStepProgress(steps: TaskStep[]): string {
@@ -88,6 +91,7 @@ export function ListView({
onTasksUpdated,
projectId,
projectName,
taskStuckTimeoutMs,
}: ListViewProps) {
const [sortField, setSortField] = useState<SortField>("id");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
@@ -849,10 +853,12 @@ export function ListView({
columnTasks.map((task) => {
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs);
const isAgentActive =
!globalPaused &&
!isFailed &&
!isPaused &&
!isStuckState &&
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDragging = draggingTaskId === task.id;
@@ -860,8 +866,10 @@ export function ListView({
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
isAgentActive ? " agent-active" : ""
}${isDragging ? " dragging" : ""}`}
isStuckState ? " stuck" : ""
}${isAgentActive ? " agent-active" : ""}${
isDragging ? " dragging" : ""
}`}
onClick={() => handleRowClick(task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
@@ -891,7 +899,11 @@ export function ListView({
)}
{visibleColumns.has("status") && (
<td className="list-cell">
{task.status ? (
{isStuckState ? (
<span className="list-status-badge stuck">
Stuck
</span>
) : task.status ? (
<span
className={`list-status-badge${isFailed ? " failed" : ""}${
isAgentActive ? " pulsing" : ""

View File

@@ -7,6 +7,7 @@ import { pickPreferredBadge } from "./TaskCardBadge";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
import { useSessionFiles } from "../hooks/useSessionFiles";
import { isTaskStuck } from "../utils/taskStuck";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -45,6 +46,8 @@ interface TaskCardProps {
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
}
function areTaskBadgeInfosEqual(
@@ -82,6 +85,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.queued === next.queued &&
previous.projectId === next.projectId &&
previous.globalPaused === next.globalPaused &&
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
previous.onOpenDetail === next.onOpenDetail &&
previous.addToast === next.addToast &&
previous.onUpdateTask === next.onUpdateTask &&
@@ -130,6 +134,7 @@ function TaskCardComponent({
onArchiveTask,
onUnarchiveTask,
onOpenFilesForTask,
taskStuckTimeoutMs,
}: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
@@ -315,9 +320,10 @@ function TaskCardComponent({
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isStuck = isTaskStuck(task, taskStuckTimeoutMs);
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
const isArchived = task.column === "archived";
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived
// Check if this card can be edited inline
@@ -498,7 +504,7 @@ function TaskCardComponent({
setShowSteps((current) => !current);
}, []);
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
if (isEditing) {
return (
@@ -560,7 +566,7 @@ function TaskCardComponent({
)}
{!isPaused && task.status && task.status !== "queued" && (
<span
className={`card-status-badge${isAwaitingApproval ? " awaiting-approval" : ""}${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}${isFailed ? " failed" : ""}`}
className={`card-status-badge${isAwaitingApproval ? " awaiting-approval" : ""}${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}${isFailed ? " failed" : ""}${isStuck ? " stuck" : ""}`}
style={isAwaitingApproval
? { background: "rgba(210,153,34,0.2)", color: "var(--triage)" }
: isFailed
@@ -568,7 +574,12 @@ function TaskCardComponent({
: { background: COLUMN_COLOR_MAP[task.column], color: COLUMN_TEXT_COLOR_MAP[task.column] }
}
>
{isAwaitingApproval ? "Awaiting Approval" : task.status}
{isStuck ? "Stuck" : isAwaitingApproval ? "Awaiting Approval" : task.status}
</span>
)}
{isStuck && (isPaused || !task.status || task.status === "queued") && (
<span className="card-status-badge stuck">
Stuck
</span>
)}
{hasGitHubBadge && (

View File

@@ -17,6 +17,8 @@ interface WorktreeGroupProps {
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
}
function WorktreeGroupComponent({
@@ -29,6 +31,7 @@ function WorktreeGroupComponent({
globalPaused,
onUpdateTask,
onOpenFilesForTask,
taskStuckTimeoutMs,
}: WorktreeGroupProps) {
return (
<div className="worktree-group">
@@ -39,7 +42,7 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenFilesForTask={onOpenFilesForTask} />
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenFilesForTask={onOpenFilesForTask} taskStuckTimeoutMs={taskStuckTimeoutMs} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -52,6 +55,7 @@ function WorktreeGroupComponent({
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onOpenFilesForTask={onOpenFilesForTask}
taskStuckTimeoutMs={taskStuckTimeoutMs}
/>
))}
</div>

View File

@@ -316,6 +316,63 @@ describe("ListView", () => {
expect(row?.className).not.toContain("agent-active");
});
it("renders stuck indicator when task is stuck and timeout is set", () => {
const staleTime = new Date(Date.now() - 600000).toISOString();
const tasks = [
createMockTask({
id: "FN-001",
status: "executing",
column: "in-progress",
updatedAt: staleTime,
}),
];
renderListView({ tasks, taskStuckTimeoutMs: 600000 });
const row = screen.getByText("FN-001").closest("tr");
expect(row?.className).toContain("stuck");
const statusBadge = screen.getByText("Stuck");
expect(statusBadge.className).toContain("stuck");
});
it("does not render stuck indicator when taskStuckTimeoutMs is undefined", () => {
const staleTime = new Date(Date.now() - 600000).toISOString();
const tasks = [
createMockTask({
id: "FN-001",
status: "executing",
column: "in-progress",
updatedAt: staleTime,
}),
];
renderListView({ tasks });
const row = screen.getByText("FN-001").closest("tr");
expect(row?.className).not.toContain("stuck");
expect(screen.getByText("executing")).toBeInTheDocument();
});
it("stuck indicator takes precedence over agent-active", () => {
const staleTime = new Date(Date.now() - 600000).toISOString();
const tasks = [
createMockTask({
id: "FN-001",
status: "executing",
column: "in-progress",
updatedAt: staleTime,
}),
];
renderListView({ tasks, taskStuckTimeoutMs: 600000, globalPaused: false });
const row = screen.getByText("FN-001").closest("tr");
expect(row?.className).toContain("stuck");
expect(row?.className).not.toContain("agent-active");
expect(screen.getByText("Stuck")).toBeInTheDocument();
});
it("renders column badges with correct colors", () => {
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;

View File

@@ -52,11 +52,11 @@ beforeEach(() => {
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
/** Mirrors the cardClass computation from TaskCard.tsx */
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column; globalPaused?: boolean }): string {
const { dragging = false, queued = false, status, column = "todo", globalPaused } = opts;
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column; globalPaused?: boolean; isStuck?: boolean; isPaused?: boolean; isAwaitingApproval?: boolean }): string {
const { dragging = false, queued = false, status, column = "todo", globalPaused, isStuck = false, isPaused = false, isAwaitingApproval = false } = opts;
const isFailed = status === "failed";
const isAgentActive = !globalPaused && !queued && !isFailed && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}`;
}
describe("TaskCard memoization", () => {
@@ -418,6 +418,36 @@ describe("TaskCard failed status", () => {
});
});
describe("TaskCard stuck status", () => {
it("applies 'stuck' class to card when task is stuck", () => {
const cls = computeCardClass({ isStuck: true, column: "in-progress", status: "executing" });
expect(cls).toContain("stuck");
});
it("does NOT apply 'stuck' class when task is not stuck", () => {
const cls = computeCardClass({ column: "in-progress", status: "executing" });
expect(cls).not.toContain("stuck");
});
it("stuck takes precedence over agent-active", () => {
const cls = computeCardClass({ isStuck: true, column: "in-progress", status: "executing" });
expect(cls).toContain("stuck");
expect(cls).not.toContain("agent-active");
});
it("stuck and failed can coexist (stuck appears in class list)", () => {
const cls = computeCardClass({ isStuck: true, status: "failed", column: "in-progress" });
expect(cls).toContain("stuck");
expect(cls).toContain("failed");
});
it("stuck and paused can coexist", () => {
const cls = computeCardClass({ isStuck: true, isPaused: true, column: "in-progress" });
expect(cls).toContain("stuck");
expect(cls).toContain("paused");
});
});
describe("TaskCard dependency tooltip", () => {
/** Mirrors the data-tooltip computation from TaskCard.tsx */
function computeDepTooltip(dependencies: string[]): string | undefined {

View File

@@ -934,6 +934,26 @@ body {
color: var(--color-error-dark);
}
.card-status-badge.stuck {
background: rgba(210, 153, 34, 0.2);
color: var(--triage);
animation: stuck-pulse 2s ease-in-out infinite;
}
.card.stuck {
border-left: 3px solid var(--triage);
background: rgba(210, 153, 34, 0.06);
}
@keyframes stuck-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.65;
}
}
/* Size badge: positioned in card header, subtle color coding for effort estimation */
.card-size-badge {
display: inline-flex;
@@ -6421,6 +6441,11 @@ body {
opacity: 0.55;
}
.list-row.stuck {
border-left: 3px solid var(--triage);
background: rgba(210, 153, 34, 0.06);
}
.list-row.agent-active {
border-left: 3px solid var(--in-progress);
animation: list-agent-glow 2.5s ease-in-out infinite;
@@ -6488,6 +6513,12 @@ body {
color: var(--color-error-dark);
}
.list-status-badge.stuck {
background: rgba(210, 153, 34, 0.2);
color: var(--triage);
animation: stuck-pulse 2s ease-in-out infinite;
}
.list-status-badge.pulsing {
animation: pulse 1.5s ease-in-out infinite;
}

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { isTaskStuck, countStuckTasks } from "./taskStuck";
import type { Task } from "@fusion/core";
const createTask = (overrides: Partial<Task> = {}): Task =>
({
id: "FN-001",
description: "Test task",
column: "in-progress",
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("isTaskStuck", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns false when timeout is undefined (disabled)", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, undefined)).toBe(false);
});
it("returns false when timeout is 0", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, 0)).toBe(false);
});
it("returns false when timeout is negative", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, -1)).toBe(false);
});
it("returns false for non-in-progress tasks", () => {
const task = createTask({ column: "todo", updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for recent in-progress tasks within timeout", () => {
const recent = new Date(Date.now() - 300000).toISOString(); // 5 minutes ago
const task = createTask({ updatedAt: recent });
expect(isTaskStuck(task, 600000)).toBe(false); // 10 minute timeout
});
it("returns true for stale in-progress tasks exceeding timeout", () => {
const stale = new Date(Date.now() - 600001).toISOString(); // just over 10 minutes
const task = createTask({ updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(true);
});
it("returns false for malformed updatedAt", () => {
const task = createTask({ updatedAt: "not-a-date" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for empty updatedAt", () => {
const task = createTask({ updatedAt: "" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("handles tasks in triage column", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ column: "triage", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("handles tasks in done column", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ column: "done", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns true exactly at timeout boundary (greater than)", () => {
const boundary = new Date(Date.now() - 600001).toISOString();
const task = createTask({ updatedAt: boundary });
expect(isTaskStuck(task, 600000)).toBe(true);
});
it("returns false exactly at timeout boundary (equal)", () => {
const boundary = new Date(Date.now() - 600000).toISOString();
const task = createTask({ updatedAt: boundary });
expect(isTaskStuck(task, 600000)).toBe(false);
});
});
describe("countStuckTasks", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns 0 when timeout is undefined", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [createTask({ updatedAt: stale })];
expect(countStuckTasks(tasks, undefined)).toBe(0);
});
it("returns 0 when timeout is 0", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [createTask({ updatedAt: stale })];
expect(countStuckTasks(tasks, 0)).toBe(0);
});
it("counts only stuck tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const recent = new Date(Date.now() - 300000).toISOString();
const tasks = [
createTask({ id: "FN-001", updatedAt: stale }), // stuck
createTask({ id: "FN-002", updatedAt: recent }), // not stuck
createTask({ id: "FN-003", column: "todo", updatedAt: stale }), // not in-progress
];
expect(countStuckTasks(tasks, 600000)).toBe(1);
});
it("returns 0 for empty task list", () => {
expect(countStuckTasks([], 600000)).toBe(0);
});
it("counts multiple stuck tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [
createTask({ id: "FN-001", updatedAt: stale }),
createTask({ id: "FN-002", updatedAt: stale }),
];
expect(countStuckTasks(tasks, 600000)).toBe(2);
});
});