feat(FN-2554): rework TaskCard timer semantics

- Split timer calculation paths so in-progress cards show live elapsed time since entering in-progress
- Make done cards show fixed processing duration from start to completion instead of growing post-completion elapsed time
- Improve timer tooltip and aria-label copy for clearer in-progress and done semantics
- Expand TaskCard tests to cover boundary formatting, fixed done-duration behavior, and stable timer output as time advances
This commit is contained in:
Fusion
2026-04-25 16:58:39 -07:00
committed by gsxdsm
parent 7e05a200b9
commit 9f25fddfae
2 changed files with 93 additions and 18 deletions

View File

@@ -100,7 +100,7 @@ function parseTimestampToMs(value?: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
function getTimeIndicatorStartMs(task: Task): number | null {
function getInProgressTimeIndicatorStartMs(task: Task): number | null {
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
const parsed = parseTimestampToMs(timestamp);
if (parsed == null) return null;
@@ -111,6 +111,25 @@ function getTimeIndicatorStartMs(task: Task): number | null {
return parsed;
}
function getDoneCompletionMs(task: Task): number | null {
const completionMs = parseTimestampToMs(task.columnMovedAt ?? task.updatedAt);
if (completionMs == null) return null;
const now = Date.now();
if (completionMs > now) return null;
return completionMs;
}
function getDoneProcessingStartMs(task: Task, completionMs: number): number | null {
const startCandidates = [task.updatedAt, task.createdAt]
.map(parseTimestampToMs)
.filter((value): value is number => value != null);
const validStart = startCandidates.find((startMs) => startMs <= completionMs);
return validStart ?? null;
}
function formatElapsedDuration(elapsedMs: number): string {
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "";
@@ -604,7 +623,7 @@ function TaskCardComponent({
return;
}
const startMs = getTimeIndicatorStartMs(task);
const startMs = getInProgressTimeIndicatorStartMs(task);
if (startMs == null) {
return;
}
@@ -622,20 +641,46 @@ function TaskCardComponent({
return null;
}
const startMs = getTimeIndicatorStartMs(task);
if (task.column === "in-progress") {
const startMs = getInProgressTimeIndicatorStartMs(task);
if (startMs == null) {
return null;
}
const elapsedLabel = formatElapsedDuration(timeIndicatorNowMs - startMs);
if (!elapsedLabel) {
return null;
}
return {
label: elapsedLabel,
title: `In progress since ${new Date(startMs).toLocaleString()}`,
ariaLabel: `Elapsed time ${elapsedLabel}. In progress since ${new Date(startMs).toLocaleString()}`,
};
}
// Done cards should report a fixed processing duration (start → completion),
// not elapsed time since the task entered the done column.
const completionMs = getDoneCompletionMs(task);
if (completionMs == null) {
return null;
}
const startMs = getDoneProcessingStartMs(task, completionMs);
if (startMs == null) {
return null;
}
const referenceNowMs = task.column === "in-progress" ? timeIndicatorNowMs : Date.now();
const elapsedLabel = formatElapsedDuration(referenceNowMs - startMs);
const elapsedLabel = formatElapsedDuration(completionMs - startMs);
if (!elapsedLabel) {
return null;
}
const completedAt = new Date(completionMs).toLocaleString();
return {
label: elapsedLabel,
title: `Since ${new Date(startMs).toLocaleString()}`,
title: `Processing took ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Completed processing duration ${elapsedLabel}. Completed ${completedAt}`,
};
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt, timeIndicatorNowMs]);
@@ -1257,7 +1302,7 @@ function TaskCardComponent({
<span
className="card-time-indicator"
title={timeIndicator.title}
aria-label={`Elapsed time ${timeIndicator.label}. ${timeIndicator.title}`}
aria-label={timeIndicator.ariaLabel}
>
<Clock size={12} />
<span>{timeIndicator.label}</span>

View File

@@ -471,10 +471,11 @@ describe("TaskCard", () => {
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("12m");
expect(timer?.getAttribute("title")).toContain("Since");
expect(timer?.getAttribute("title")).toContain("In progress since");
expect(timer?.getAttribute("aria-label")).toContain("Elapsed time 12m");
});
it("shows timer chip for done cards when timestamp fields exist", () => {
it("shows fixed processing-duration timer chip for done cards", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
@@ -493,7 +494,9 @@ describe("TaskCard", () => {
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("3h");
expect(timer?.textContent).toContain("1h");
expect(timer?.getAttribute("title")).toContain("Processing took 1h");
expect(timer?.getAttribute("aria-label")).toContain("Completed processing duration 1h");
});
it("renders files-changed metadata and timer chip in the same footer row", () => {
@@ -576,20 +579,20 @@ describe("TaskCard", () => {
});
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 }) => {
{ durationMs: 59_000, expected: "<1m" },
{ durationMs: 60 * 60_000, expected: "1h" },
{ durationMs: 24 * 60 * 60_000, expected: "1d" },
])("formats done processing-duration label as $expected at boundary", ({ durationMs, expected }) => {
vi.useFakeTimers();
const now = new Date("2026-04-25T20:00:00.000Z");
vi.setSystemTime(now);
const completionTime = new Date("2026-04-25T20:00:00.000Z");
vi.setSystemTime(new Date("2026-04-25T23:00:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
columnMovedAt: new Date(now.getTime() - elapsedMs).toISOString(),
updatedAt: "2026-04-25T10:00:00.000Z",
columnMovedAt: completionTime.toISOString(),
updatedAt: new Date(completionTime.getTime() - durationMs).toISOString(),
createdAt: "2026-04-25T09:00:00.000Z",
})}
onOpenDetail={noop}
@@ -598,9 +601,36 @@ describe("TaskCard", () => {
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain(expected);
});
it("keeps done processing-duration timer stable when clock advances", () => {
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}
/>,
);
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1h");
act(() => {
vi.advanceTimersByTime(2 * 60 * 60_000);
});
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1h");
});
it("refreshes in-progress timer chip on 30s cadence", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:00:30.000Z"));