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:
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user