From 67d4d51aebacc6641c4643c7203d8058ad114c79 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 19:17:36 -0700 Subject: [PATCH] FN-6413: move task-card timer to footer cluster Move the task timing badge out of the header/meta row and into the footer's bottom-right badge cluster. - Render the time indicator with footer chips so it aligns with retry and GitHub badges. - Reuse footer badge sizing for the timer across desktop, wrapping, and mobile layouts. - Update TaskCard layout tests to assert footer placement and add a patch changeset. Files changed: .changeset/fn-6413-task-card-timing-footer.md | 5 + packages/dashboard/app/components/TaskCard.css | 8 +- packages/dashboard/app/components/TaskCard.tsx | 31 ++--- .../__tests__/TaskCard.footer-alignment.test.tsx | 13 +- .../__tests__/TaskCard.footer-wrap.test.tsx | 1 + .../app/components/__tests__/TaskCard.test.tsx | 138 ++++++++++++++------- .../app/components/__tests__/board-mobile.test.tsx | 5 +- 7 files changed, 130 insertions(+), 71 deletions(-) Fusion-Task-Id: FN-6413 Fusion-Task-Lineage: a82da5ab-20c6-4dfa-92a3-150306011fd6 --- .changeset/fn-6413-task-card-timing-footer.md | 5 + .../dashboard/app/components/TaskCard.css | 8 +- .../dashboard/app/components/TaskCard.tsx | 31 ++-- .../TaskCard.footer-alignment.test.tsx | 13 +- .../__tests__/TaskCard.footer-wrap.test.tsx | 1 + .../components/__tests__/TaskCard.test.tsx | 138 ++++++++++++------ .../__tests__/board-mobile.test.tsx | 5 +- 7 files changed, 130 insertions(+), 71 deletions(-) create mode 100644 .changeset/fn-6413-task-card-timing-footer.md diff --git a/.changeset/fn-6413-task-card-timing-footer.md b/.changeset/fn-6413-task-card-timing-footer.md new file mode 100644 index 0000000000..28ec81aae3 --- /dev/null +++ b/.changeset/fn-6413-task-card-timing-footer.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Move task-card timing badges from the top metadata cluster into the bottom-right footer chip cluster so timers align with retry and GitHub footer badges. diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 4a52fbb7f5..bc035b2324 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -760,10 +760,10 @@ align-items: center; } -.card-footer-row > .card-time-indicator:first-of-type { - margin-left: auto; -} - +/* +FNXC:TaskCardTimingBadge 2026-06-13-17:26: +The execution-time badge is part of the footer's bottom-right chip cluster, so it inherits the same height, padding, font, and responsive sizing as retry and GitHub tracking badges instead of using the old meta-row placement. +*/ .card-time-indicator, .card-github-tracking-chip, .card-retry-badge, diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 696b7346bc..b45013dea9 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -1789,8 +1789,7 @@ function TaskCardComponent({ && Boolean(githubTrackedIssue); const hasCardMetaBadges = showPriorityBadge || task.executionMode === "fast" - || isAgentCreated - || timeIndicator != null; + || isAgentCreated; if (isEditing) { return ( @@ -2012,16 +2011,6 @@ function TaskCardComponent({ )} - {timeIndicator && ( - - - {timeIndicator.label} - - )} )} {task.noCommitsExpected === true && ( @@ -2289,7 +2278,7 @@ function TaskCardComponent({ ); })()} - {(filesChangedButton || isGitHubImportedTask || showNearDuplicateChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && ( + {(filesChangedButton || isGitHubImportedTask || timeIndicator || showNearDuplicateChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
{filesChangedButton} {isGitHubImportedTask && !showLinkedIssueChipForImport && ( @@ -2301,7 +2290,7 @@ function TaskCardComponent({ )} - {(showNearDuplicateChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && ( + {(timeIndicator || showNearDuplicateChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0) && (
{showNearDuplicateChip && ( <> @@ -2374,6 +2363,20 @@ function TaskCardComponent({ {`#${githubTrackedIssue.number}`} )} + {/* + FNXC:TaskCardTimingBadge 2026-06-13-17:20: + The execution-time badge belongs in the bottom-right footer cluster and must match sibling footer badge sizing while preserving its existing label, title, aria text, and live-update data. + */} + {timeIndicator && ( + + + {timeIndicator.label} + + )}
)}
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.footer-alignment.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.footer-alignment.test.tsx index f2691558a4..6694fde809 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.footer-alignment.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.footer-alignment.test.tsx @@ -125,7 +125,7 @@ describe("FN-4598 TaskCard footer chip alignment", () => { } }); - it("keeps github and retry right-aligned while timer joins card meta badges", () => { + it("keeps github, retry, and timer right-aligned in the footer cluster", () => { const { container } = render( { column: "in-review", retrySummary: { total: 2 }, executionStartedAt: "2026-05-12T00:00:00.000Z", + executionCompletedAt: "2026-05-12T00:05:00.000Z", updatedAt: "2026-05-12T00:05:00.000Z", }} onOpenDetail={noop} @@ -146,14 +147,14 @@ describe("FN-4598 TaskCard footer chip alignment", () => { const rightCluster = footerRow.querySelector(":scope > .card-footer-row-right") as HTMLElement; const githubChip = rightCluster.querySelector(":scope > .card-github-tracking-chip") as HTMLElement; const retryChip = rightCluster.querySelector(":scope > .card-retry-badge") as HTMLElement; - const timerChip = container.querySelector(".card-meta-badges > .card-time-indicator") as HTMLElement; + const timerChip = rightCluster.querySelector(":scope > .card-time-indicator") as HTMLElement; expect(footerRow).toBeTruthy(); expect(rightCluster).toBeTruthy(); expect(githubChip).toBeTruthy(); expect(retryChip).toBeTruthy(); expect(timerChip).toBeTruthy(); - expect(rightCluster.contains(timerChip)).toBe(false); + expect(rightCluster.contains(timerChip)).toBe(true); expect(getComputedStyle(rightCluster).marginLeft).toBe("auto"); @@ -170,6 +171,7 @@ describe("FN-4598 TaskCard footer chip alignment", () => { sourceType: "github_import", retrySummary: { total: 3 }, executionStartedAt: "2026-05-12T00:00:00.000Z", + executionCompletedAt: "2026-05-12T00:05:00.000Z", updatedAt: "2026-05-12T00:05:00.000Z", }} onOpenDetail={noop} @@ -183,7 +185,7 @@ describe("FN-4598 TaskCard footer chip alignment", () => { const rightCluster = footerRow.querySelector(":scope > .card-footer-row-right") as HTMLElement; const retryChip = rightCluster.querySelector(":scope > .card-retry-badge") as HTMLElement; const githubChip = rightCluster.querySelector(":scope > .card-github-tracking-chip") as HTMLElement; - const timerChip = container.querySelector(".card-meta-badges > .card-time-indicator") as HTMLElement; + const timerChip = rightCluster.querySelector(":scope > .card-time-indicator") as HTMLElement; expect(footerRow).toBeTruthy(); expect(sourceChip).toBeTruthy(); @@ -191,13 +193,14 @@ describe("FN-4598 TaskCard footer chip alignment", () => { expect(retryChip).toBeTruthy(); expect(githubChip).toBeTruthy(); expect(timerChip).toBeTruthy(); - expect(rightCluster.contains(timerChip)).toBe(false); + expect(rightCluster.contains(timerChip)).toBe(true); expect(getComputedStyle(sourceChip).marginLeft).not.toBe("auto"); expect(getComputedStyle(rightCluster).marginLeft).toBe("auto"); expect(Array.from(rightCluster.children).map((node) => (node as HTMLElement).className)).toEqual([ "card-github-tracking-chip card-github-tracking-link", expect.stringContaining("card-retry-badge"), + "card-time-indicator", ]); }); }); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.footer-wrap.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.footer-wrap.test.tsx index 9c139274cc..62840c5456 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.footer-wrap.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.footer-wrap.test.tsx @@ -124,6 +124,7 @@ describe("TaskCard footer wrapping (FN-5210)", () => { expect(retryChip).toBeTruthy(); expect(githubChip).toBeTruthy(); expect(timeChip).toBeTruthy(); + expect(rightCluster.contains(timeChip)).toBe(true); const footerStyles = getComputedStyle(footerRow); expect(footerStyles.flexWrap).toBe("wrap"); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index c56e09db53..75e346be9b 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -137,6 +137,16 @@ function mountCssForBadgeTests() { }; } +function expectTimerInFooterRight(container: HTMLElement) { + const timer = container.querySelector(".card-time-indicator"); + const footerRow = container.querySelector(".card-footer-row"); + const rightCluster = container.querySelector(".card-footer-row-right"); + expect(timer).not.toBeNull(); + expect(footerRow?.contains(timer)).toBe(true); + expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster); + expect(timer?.closest(".card-meta-badges")).toBeNull(); +} + const highFanout = { totalCount: 7, activeTodoCount: 3, @@ -1602,7 +1612,9 @@ describe("TaskCard", () => { expect(screen.getByTestId("icon-zap")).toBeDefined(); }); - it("groups priority, fast mode, agent-created, and time metadata in one badge row", () => { + + it("keeps priority, fast mode, and agent-created in meta while time moves to footer", () => { + const { container } = render( { const group = container.querySelector(".card-meta-badges"); expect(group).not.toBeNull(); - const expectedSelectors = [ + + const expectedMetaSelectors = [ ".card-priority-badge", ".card-execution-mode-badge", ".card-agent-created-badge", - ".card-time-indicator", ]; - expectedSelectors.forEach((selector) => { + expectedMetaSelectors.forEach((selector) => { + const badge = container.querySelector(selector); expect(badge).not.toBeNull(); expect(badge?.closest(".card-meta-badges")).toBe(group); }); + + const timer = container.querySelector(".card-time-indicator"); + expect(timer).not.toBeNull(); + expect(timer?.closest(".card-meta-badges")).toBeNull(); + expect(timer?.closest(".card-footer-row-right")).not.toBeNull(); + expect(Array.from(group?.children ?? []).map((child) => child.className)).toEqual([ "card-priority-badge card-priority-badge--high", "card-execution-mode-badge card-execution-mode-badge--fast", "card-agent-created-badge", - "card-time-indicator", + + ]); }); @@ -1665,7 +1685,9 @@ describe("TaskCard", () => { expect(container.querySelector(".card-footer-row-right")).toBeNull(); }); - it("moves a lone time chip into card meta badges without rendering an empty footer", () => { + + it("moves a lone time chip into the footer without rendering empty meta badges", () => { + vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-25T12:05:00.000Z")); @@ -1683,14 +1705,19 @@ describe("TaskCard", () => { const group = container.querySelector(".card-meta-badges"); const timer = container.querySelector(".card-time-indicator"); - expect(group).not.toBeNull(); + + const footerRow = container.querySelector(".card-footer-row"); + const rightCluster = container.querySelector(".card-footer-row-right"); + expect(group).toBeNull(); expect(timer).not.toBeNull(); - expect(timer?.closest(".card-meta-badges")).toBe(group); + expect(footerRow).not.toBeNull(); + expect(rightCluster).not.toBeNull(); + expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster); + expect(Array.from(rightCluster?.children ?? [])).toEqual([timer]); expect(container.querySelector(".card-priority-badge")).toBeNull(); expect(container.querySelector(".card-execution-mode-badge")).toBeNull(); expect(container.querySelector(".card-agent-created-badge")).toBeNull(); - expect(container.querySelector(".card-footer-row")).toBeNull(); - expect(container.querySelector(".card-footer-row-right")).toBeNull(); + }); it("does not render card meta badge shells when all grouped affordances are absent", () => { @@ -2335,7 +2362,7 @@ describe("TaskCard", () => { ); const timer = container.querySelector(".card-time-indicator"); - expect(timer).not.toBeNull(); + expectTimerInFooterRight(container); // 8m workflow + 4m timed = 12m expect(timer?.textContent).toContain("12m"); expect(timer?.getAttribute("title")).toContain("In progress 12m"); @@ -2354,6 +2381,7 @@ describe("TaskCard", () => { ); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m"); + expectTimerInFooterRight(container); rerender( { ); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("2m"); + expectTimerInFooterRight(container); }); it("shows timer chip for done cards summing workflow runtime + timed events", () => { @@ -2401,7 +2430,7 @@ describe("TaskCard", () => { ); const timer = container.querySelector(".card-time-indicator"); - expect(timer).not.toBeNull(); + expectTimerInFooterRight(container); // 1h workflow + 1h timed = 2h expect(timer?.textContent).toContain("2h"); expect(timer?.getAttribute("title")).toContain("Execution time 2h"); @@ -2512,7 +2541,9 @@ describe("TaskCard", () => { expect(queuedBadge?.compareDocumentPosition(footerRow as Node) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy(); }); - it("renders tracking and retry in the footer while timer joins the card meta badges", () => { + + it("renders tracking, retry, and timer in the footer right cluster", () => { + const { container } = render( { const footerRow = container.querySelector(".card-footer-row"); const metaBadges = container.querySelector(".card-meta-badges"); + + const rightCluster = container.querySelector(".card-footer-row-right"); + const trackingLink = container.querySelector(".card-github-tracking-chip"); const retryChip = container.querySelector(".card-retry-badge"); const timerChip = container.querySelector(".card-time-indicator"); expect(footerRow).not.toBeNull(); - expect(metaBadges).not.toBeNull(); + + expect(metaBadges).toBeNull(); + expect(rightCluster).not.toBeNull(); expect(footerRow?.contains(trackingLink)).toBe(true); expect(footerRow?.contains(retryChip)).toBe(true); - expect(footerRow?.contains(timerChip)).toBe(false); - expect(metaBadges?.contains(timerChip)).toBe(true); + expect(footerRow?.contains(timerChip)).toBe(true); + expect(rightCluster?.contains(timerChip)).toBe(true); + expect(Array.from(rightCluster?.children ?? [])).toContain(timerChip); + expect(container.querySelector(".card-bottom-right-row")).toBeNull(); }); @@ -2745,7 +2783,9 @@ describe("TaskCard", () => { expect(container.querySelector(".card-footer-row > .card-source-provenance")).toBeNull(); }); - it("keeps github badges before retry while time chip joins card meta badges", () => { + + it("keeps github badges before retry while time chip ends the footer cluster", () => { + const { container } = render( { const timerChip = container.querySelector(".card-time-indicator"); expect(sourceNode).not.toBeNull(); expect(rightCluster).not.toBeNull(); - expect(timerChip?.closest(".card-meta-badges")).not.toBeNull(); + + expect(timerChip?.closest(".card-footer-row-right")).toBe(rightCluster); const orderedNodes = [ rightCluster?.querySelector(".card-github-tracking-chip"), rightCluster?.querySelector(".card-retry-badge"), + timerChip, + ]; orderedNodes.forEach((node) => expect(node).not.toBeNull()); expect(Array.from((rightCluster as Element).children)).toEqual(orderedNodes); @@ -2838,17 +2881,15 @@ describe("TaskCard", () => { const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(rightCluster).not.toBeNull(); const children = Array.from((rightCluster as HTMLElement).children); - if (rightSideChip?.classList.contains("card-time-indicator")) { - expect(children.at(-1)).toBe(trackingChip); - expect(rightSideChip.closest(".card-meta-badges")).not.toBeNull(); - } else { - expect(children.at(-1)).toBe(rightSideChip); - expect(children.indexOf(rightSideChip as HTMLElement)).toBeGreaterThan(children.indexOf(trackingChip as HTMLElement)); - } + + expect(children).toContain(trackingChip as HTMLElement); + expect(children).toContain(rightSideChip as HTMLElement); + expect(children.indexOf(rightSideChip as HTMLElement)).toBeGreaterThan(children.indexOf(trackingChip as HTMLElement)); expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto"); }); - it.each(["in-progress", "in-review"] as const)("renders time indicator in meta badges beside footer tracking chip for %s", (column) => { + it.each(["in-progress", "in-review"] as const)("renders time indicator in footer right cluster beside tracking chip for %s", (column) => { + const { container } = render( { expect(rightCluster).not.toBeNull(); const children = Array.from((rightCluster as HTMLElement).children); expect(children).toContain(trackingChip); - expect(children).not.toContain(timeChip); - expect(timeChip?.closest(".card-meta-badges")).not.toBeNull(); + + expect(children).toContain(timeChip); + expect(timeChip?.closest(".card-footer-row-right")).toBe(rightCluster); + }); it("does not force far-right modifier when in-progress card has files changed", () => { @@ -2940,13 +2983,11 @@ describe("TaskCard", () => { const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(rightCluster).not.toBeNull(); const children = Array.from((rightCluster as HTMLElement).children); - if (rightSideChip?.classList.contains("card-time-indicator")) { - expect(children.at(-1)).toBe(trackingChip); - expect(rightSideChip.closest(".card-meta-badges")).not.toBeNull(); - } else { - expect(children.at(-1)).toBe(rightSideChip); - expect(children.indexOf(rightSideChip as HTMLElement)).toBeGreaterThan(children.indexOf(trackingChip as HTMLElement)); - } + + expect(children).toContain(trackingChip as HTMLElement); + expect(children).toContain(rightSideChip as HTMLElement); + expect(children.indexOf(rightSideChip as HTMLElement)).toBeGreaterThan(children.indexOf(trackingChip as HTMLElement)); + expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto"); }); @@ -3010,8 +3051,10 @@ describe("TaskCard", () => { const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(rightCluster).not.toBeNull(); expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto"); - expect((trackingChip as HTMLElement).nextElementSibling).toBeNull(); - expect(timerChip?.closest(".card-meta-badges")).not.toBeNull(); + + expect((trackingChip as HTMLElement).nextElementSibling).toBe(timerChip); + expect(timerChip?.closest(".card-footer-row-right")).toBe(rightCluster); + } finally { cleanupCss(); } @@ -3798,12 +3841,15 @@ describe("TaskCard", () => { expect(footerRow).not.toBeNull(); expect(filesChanged).not.toBeNull(); expect(timer).not.toBeNull(); + const rightCluster = container.querySelector(".card-footer-row-right"); expect(footerRow?.contains(filesChanged)).toBe(true); - expect(footerRow?.contains(timer)).toBe(false); - expect(header?.contains(timer)).toBe(true); - expect(timer?.closest(".card-meta-badges")).not.toBeNull(); - expect(container.querySelector(".card-footer-row-right")).toBeNull(); - expect(Array.from(footerRow?.children ?? [])).toEqual([filesChanged]); + + expect(footerRow?.contains(timer)).toBe(true); + expect(header?.contains(timer)).toBe(false); + expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster); + expect(rightCluster).not.toBeNull(); + expect(Array.from(footerRow?.children ?? [])).toEqual([filesChanged, rightCluster]); + }); it("shows timer chip for in-review cards", () => { @@ -3835,7 +3881,7 @@ describe("TaskCard", () => { ); const timer = container.querySelector(".card-time-indicator"); - expect(timer).not.toBeNull(); + expectTimerInFooterRight(container); expect(timer?.textContent).toContain("12m"); expect(timer?.getAttribute("title")).toContain("Execution time 12m"); expect(timer?.getAttribute("title")).not.toContain("Completed"); @@ -3859,7 +3905,7 @@ describe("TaskCard", () => { ); const timer = container.querySelector(".card-time-indicator"); - expect(timer).not.toBeNull(); + expectTimerInFooterRight(container); expect(timer?.textContent).toContain("30m"); expect(timer?.getAttribute("title")).toBe("Execution time 30m"); @@ -3891,7 +3937,7 @@ describe("TaskCard", () => { ); const timer = container.querySelector(".card-time-indicator"); - expect(timer).not.toBeNull(); + expectTimerInFooterRight(container); expect(timer?.textContent).toContain("6m"); expect(timer?.getAttribute("title")).toBe("Execution time 6m"); }); @@ -3945,7 +3991,7 @@ describe("TaskCard", () => { ); const timer = container.querySelector(".card-time-indicator"); - expect(timer).not.toBeNull(); + expectTimerInFooterRight(container); expect(timer?.textContent).toContain("45m"); expect(timer?.getAttribute("title")).toBe("Execution time 45m. Merge phase <1m"); } finally { diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx index 614627a987..cd34ec435e 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx @@ -339,9 +339,10 @@ describe("TaskCard mobile", () => { expectRuleToContain(css, ".card-footer-row", "row-gap: var(--space-xs);"); }); - it("keeps TaskCard footer-chip cluster anchored by first chip rules", () => { + it("keeps TaskCard footer-chip cluster anchored by the right wrapper", () => { const css = loadAllAppCss(); - expectRuleToContain(css, ".card-footer-row > .card-time-indicator:first-of-type", "margin-left: auto;"); + expectRuleToContain(css, ".card-footer-row-right", "margin-left: auto;"); + expect(css).not.toContain(".card-footer-row > .card-time-indicator:first-of-type"); const timeIndicatorRule = css.match(/\.card-time-indicator\s*\{[^}]*\}/)?.[0] ?? ""; expect(timeIndicatorRule).not.toContain("margin-left: auto;");