feat(FN-2512): add elapsed timer chip to task cards
- Derive task elapsed time from columnMovedAt with updatedAt/createdAt fallbacks and guard against invalid or future timestamps - Render a clock-based timer chip on in-progress and done cards with accessible labeling and tooltip metadata - Add TaskCard styles for the timer row/chip using design tokens, including mobile-size adjustments - Expand TaskCard tests to cover visibility by column, invalid timestamp suppression, boundary label formatting, and 30s live refresh cadence
This commit is contained in:
@@ -823,6 +823,7 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
|
||||
it("shows loading state while fetching chain of command", async () => {
|
||||
const resolvedChain = [{ id: "agent-001", name: "Test Agent" } as AgentDetail];
|
||||
const resolveChainCalls: Array<(agents: AgentDetail[]) => void> = [];
|
||||
mockFetchChainOfCommand.mockImplementation(
|
||||
() =>
|
||||
@@ -843,9 +844,17 @@ describe("AgentDetailView", () => {
|
||||
expect(screen.getByText("Loading reporting chain...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// React Strict Mode and concurrent rendering can trigger extra effect passes.
|
||||
// Make late calls auto-resolve so the loading state can settle deterministically.
|
||||
mockFetchChainOfCommand.mockResolvedValue(resolvedChain as any);
|
||||
|
||||
await act(async () => {
|
||||
for (const resolve of resolveChainCalls) {
|
||||
resolve([{ id: "agent-001", name: "Test Agent" } as AgentDetail]);
|
||||
// Allow any additional in-flight calls to register before resolving all pendings.
|
||||
await Promise.resolve();
|
||||
while (resolveChainCalls.length > 0) {
|
||||
const resolve = resolveChainCalls.shift();
|
||||
resolve?.(resolvedChain);
|
||||
await Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { TaskCard } from "../TaskCard";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("lucide-react", () => ({
|
||||
CircleDot: () => null,
|
||||
Target: () => null,
|
||||
Bot: () => null,
|
||||
Trash2: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
@@ -42,6 +43,10 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("TaskCard", () => {
|
||||
it("renders the card ID text", () => {
|
||||
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
||||
@@ -437,6 +442,147 @@ describe("TaskCard", () => {
|
||||
expect(archiveBtn).not.toBeNull();
|
||||
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows timer chip for in-progress cards when timestamp fields exist", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "in-progress",
|
||||
columnMovedAt: "2026-04-25T12:18:00.000Z",
|
||||
updatedAt: "2026-04-25T12:10:00.000Z",
|
||||
createdAt: "2026-04-25T12:00:00.000Z",
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timer = container.querySelector(".card-time-indicator");
|
||||
expect(timer).not.toBeNull();
|
||||
expect(timer?.textContent).toContain("12m");
|
||||
expect(timer?.getAttribute("title")).toContain("Since");
|
||||
});
|
||||
|
||||
it("shows timer chip for done cards when timestamp fields exist", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "done",
|
||||
columnMovedAt: "2026-04-25T15:00:00.000Z",
|
||||
updatedAt: "2026-04-25T14:00:00.000Z",
|
||||
createdAt: "2026-04-25T13:00:00.000Z",
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timer = container.querySelector(".card-time-indicator");
|
||||
expect(timer).not.toBeNull();
|
||||
expect(timer?.textContent).toContain("3h");
|
||||
});
|
||||
|
||||
it.each(["triage", "todo", "in-review", "archived"] as const)(
|
||||
"does not render timer chip for %s cards",
|
||||
(column) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column,
|
||||
columnMovedAt: "2026-04-25T15:00:00.000Z",
|
||||
updatedAt: "2026-04-25T14:00:00.000Z",
|
||||
createdAt: "2026-04-25T13:00:00.000Z",
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".card-time-indicator")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("suppresses timer chip when all timestamp fallbacks are invalid or missing", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "in-progress",
|
||||
columnMovedAt: "not-a-date",
|
||||
updatedAt: "also-not-a-date",
|
||||
createdAt: undefined as unknown as string,
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".card-time-indicator")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ elapsedMs: 59_000, expected: "<1m" },
|
||||
{ elapsedMs: 60 * 60_000, expected: "1h" },
|
||||
{ elapsedMs: 24 * 60 * 60_000, expected: "1d" },
|
||||
])("formats elapsed timer label as $expected at boundary", ({ elapsedMs, expected }) => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-04-25T20:00:00.000Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "done",
|
||||
columnMovedAt: new Date(now.getTime() - elapsedMs).toISOString(),
|
||||
updatedAt: "2026-04-25T10:00:00.000Z",
|
||||
createdAt: "2026-04-25T09:00:00.000Z",
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timer = container.querySelector(".card-time-indicator");
|
||||
expect(timer?.textContent).toContain(expected);
|
||||
});
|
||||
|
||||
it("refreshes in-progress timer chip on 30s cadence", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-25T12:00:30.000Z"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "in-progress",
|
||||
columnMovedAt: "2026-04-25T12:00:00.000Z",
|
||||
updatedAt: "2026-04-25T11:59:00.000Z",
|
||||
createdAt: "2026-04-25T11:58:00.000Z",
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timer = container.querySelector(".card-time-indicator");
|
||||
expect(timer?.textContent).toContain("<1m");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard mission badge", () => {
|
||||
|
||||
Reference in New Issue
Block a user