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:
142
packages/dashboard/app/utils/taskStuck.test.ts
Normal file
142
packages/dashboard/app/utils/taskStuck.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user