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:
@@ -444,6 +444,28 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.card-time-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-time-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 30%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
@@ -922,6 +944,15 @@
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.card-time-row {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.card-time-indicator {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Card: smaller status badges for 280px width */
|
||||
.card-status-badge {
|
||||
font-size: 9px;
|
||||
|
||||
@@ -91,6 +91,40 @@ const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
|
||||
archived: "var(--text-muted)",
|
||||
};
|
||||
|
||||
const TIME_INDICATOR_COLUMNS = new Set<Column>(["in-progress", "done"]);
|
||||
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
|
||||
|
||||
function parseTimestampToMs(value?: string): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function getTimeIndicatorStartMs(task: Task): number | null {
|
||||
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
|
||||
const parsed = parseTimestampToMs(timestamp);
|
||||
if (parsed == null) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (parsed > now) return null;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatElapsedDuration(elapsedMs: number): string {
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "";
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedMs / 60_000);
|
||||
if (elapsedMinutes < 1) return "<1m";
|
||||
if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
|
||||
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
if (elapsedHours < 24) return `${elapsedHours}h`;
|
||||
|
||||
const elapsedDays = Math.floor(elapsedHours / 24);
|
||||
return `${elapsedDays}d`;
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
projectId?: string;
|
||||
@@ -318,6 +352,7 @@ function TaskCardComponent({
|
||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||
const [agentName, setAgentName] = useState<string | null>(null);
|
||||
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
|
||||
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const touchOpenHandledRef = useRef(false);
|
||||
@@ -564,6 +599,46 @@ function TaskCardComponent({
|
||||
const showProgressSection =
|
||||
unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress");
|
||||
|
||||
useEffect(() => {
|
||||
if (task.column !== "in-progress") {
|
||||
return;
|
||||
}
|
||||
|
||||
const startMs = getTimeIndicatorStartMs(task);
|
||||
if (startMs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeIndicatorNowMs(Date.now());
|
||||
const interval = window.setInterval(() => {
|
||||
setTimeIndicatorNowMs(Date.now());
|
||||
}, LIVE_TIME_INDICATOR_POLL_MS);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt]);
|
||||
|
||||
const timeIndicator = useMemo(() => {
|
||||
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startMs = getTimeIndicatorStartMs(task);
|
||||
if (startMs == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const referenceNowMs = task.column === "in-progress" ? timeIndicatorNowMs : Date.now();
|
||||
const elapsedLabel = formatElapsedDuration(referenceNowMs - startMs);
|
||||
if (!elapsedLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: elapsedLabel,
|
||||
title: `Since ${new Date(startMs).toLocaleString()}`,
|
||||
};
|
||||
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt, timeIndicatorNowMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasGitHubBadge || !isInViewport) {
|
||||
unsubscribeFromBadge(task.id);
|
||||
@@ -1162,6 +1237,18 @@ function TaskCardComponent({
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{timeIndicator && (
|
||||
<div className="card-time-row">
|
||||
<span
|
||||
className="card-time-indicator"
|
||||
title={timeIndicator.title}
|
||||
aria-label={`Elapsed time ${timeIndicator.label}. ${timeIndicator.title}`}
|
||||
>
|
||||
<Clock size={12} />
|
||||
<span>{timeIndicator.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
||||
<div className="card-meta">
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
|
||||
@@ -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