From 716ba1778069f5f916f34515de79a1f0c417511e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 27 Jul 2026 15:19:57 -0400 Subject: [PATCH 1/4] Show billed amount in runs list size tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Size column in the runs list rendered SizeChip without the billed total, so its tooltip read "Size M" while the run detail header showed "Size M · $12.34 billed". The tooltip was also unreachable: the row title link paints a `before:absolute before:inset-0` overlay across the whole row, which sat above the chip and swallowed hover. Wrapping the chip in `relative z-10` lifts it above that overlay, matching how the created-by and pull request cells already handle interactive content. Runs without terminal billing keep the plain "Size M" label, same as the header. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/runs-list/run-table-row.tsx | 6 +++++- apps/fabro-web/app/data/runs.test.ts | 11 +++++++++++ apps/fabro-web/app/data/runs.ts | 2 ++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/fabro-web/app/components/runs-list/run-table-row.tsx b/apps/fabro-web/app/components/runs-list/run-table-row.tsx index 850c36467..e7447bce5 100644 --- a/apps/fabro-web/app/components/runs-list/run-table-row.tsx +++ b/apps/fabro-web/app/components/runs-list/run-table-row.tsx @@ -116,7 +116,11 @@ export function RunTableRow({ )} {show("size") && ( - {run.size != null && } + {run.size != null && ( + + + + )} )} {show("changes") && ( diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 0ad593526..77fdc91e2 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -103,6 +103,17 @@ describe("mapRunListItem", () => { expect(mapRunListItem(summary).title).toBe("Untitled run"); }); + + test("carries the billed total so the size chip can show it on hover", () => { + expect(mapRunListItem(makeRun()).totalUsdMicros).toBe(500000); + }); + + test("leaves the billed total undefined for runs without terminal billing", () => { + expect(mapRunListItem(makeRun({ billing: null })).totalUsdMicros).toBeUndefined(); + expect( + mapRunListItem(makeRun({ billing: { total_usd_micros: null } })).totalUsdMicros, + ).toBeUndefined(); + }); }); describe("mapRunToRunItem", () => { diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index 4fb704133..c5f29f6ae 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -46,6 +46,7 @@ export interface RunItem { createdBy: Principal; lastEventAt?: string; size?: RunSize; + totalUsdMicros?: number; } export const columnStatuses = [ @@ -119,6 +120,7 @@ export function mapRunListItem(item: Run): RunItem { additions: item.diff?.additions, deletions: item.diff?.deletions, size: item.size, + totalUsdMicros: item.billing?.total_usd_micros ?? undefined, }; } From 991f160a0b09f54f931fe27813ccc011779073ec Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 27 Jul 2026 15:23:01 -0400 Subject: [PATCH 2/4] Drop "billed" from the size chip tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tooltip now reads "Size M · $12.34" instead of "Size M · $12.34 billed". Co-Authored-By: Claude Opus 5 (1M context) --- apps/fabro-web/app/components/size-chip.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/fabro-web/app/components/size-chip.tsx b/apps/fabro-web/app/components/size-chip.tsx index e3d07ed40..d23210955 100644 --- a/apps/fabro-web/app/components/size-chip.tsx +++ b/apps/fabro-web/app/components/size-chip.tsx @@ -19,10 +19,10 @@ export function SizeChip({ totalUsdMicros?: number | null; }) { const tone = SIZE_TONE[size]; - const billed = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)} billed` : ""; + const amount = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)}` : ""; const tooltip = tone.note != null - ? `Size ${size} (${tone.note})${billed}` - : `Size ${size}${billed}`; + ? `Size ${size} (${tone.note})${amount}` + : `Size ${size}${amount}`; return ( From 53c580ce5fde89d4ef348bf091da8074c5a66aee Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 27 Jul 2026 15:25:44 -0400 Subject: [PATCH 3/4] Swap the board card's elapsed time for a size chip The board cards showed wall-clock duration in the footer's bottom-right corner. Replace it with the same SizeChip the list view and run detail header use, so the cost signal is consistent across all three views. The chip inherits the tooltip, which names the tier and adds the cost once a run has terminal billing. Add SizeChip tests pinning the tooltip label for each tier. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/size-chip.test.tsx | 40 +++++++++++++++++++ apps/fabro-web/app/routes/runs.tsx | 11 ++--- 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 apps/fabro-web/app/components/size-chip.test.tsx diff --git a/apps/fabro-web/app/components/size-chip.test.tsx b/apps/fabro-web/app/components/size-chip.test.tsx new file mode 100644 index 000000000..b355f5db1 --- /dev/null +++ b/apps/fabro-web/app/components/size-chip.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import { SizeChip } from "./size-chip"; +import { Tooltip } from "./ui"; + +function tooltipLabel(element: React.ReactElement): string { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create(element); + }); + return renderer!.root.findByType(Tooltip).props.label as string; +} + +describe("SizeChip", () => { + test("renders the size letter", () => { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create(); + }); + + expect(JSON.stringify(renderer!.toJSON())).toContain("M"); + }); + + test("appends the cost to the tooltip", () => { + expect(tooltipLabel()) + .toBe("Size M · $12.34"); + }); + + test("omits the cost when the run has no billing yet", () => { + expect(tooltipLabel()).toBe("Size M"); + expect(tooltipLabel()).toBe("Size M"); + }); + + test("calls out the tiers that warrant attention", () => { + expect(tooltipLabel()) + .toBe("Size L (risky) · $150.00"); + expect(tooltipLabel()).toBe("Size XL (unhealthy)"); + }); +}); diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 7da0719ea..fe21e36c8 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -26,6 +26,7 @@ import { ciConfig, columnForRun, columnStatusDisplay, columnStatuses, deriveCiSt import type { CiStatus, CheckRun, CheckStatus, RunItem } from "../data/runs"; import { EmptyState } from "../components/state"; import { PullRequestChip } from "../components/pull-request-chip"; +import { SizeChip } from "../components/size-chip"; import { summarizeBatchLifecycleAction, } from "../components/runs-list/batch-lifecycle"; @@ -345,7 +346,7 @@ function PrCard({ // All inline footer metadata on PrCard belongs in this one row. Adding a new // piece as a sibling `
` below the card body recreates a recurring bug -// where stats stack onto separate lines instead of sitting next to elapsed/actions. +// where stats stack onto separate lines instead of sitting next to size/actions. function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) { const hasActions = actions != null && actions.length > 0; const hasStats = @@ -354,7 +355,7 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) { (pr.additions != null && pr.additions !== 0) || (pr.deletions != null && pr.deletions !== 0); - if (!hasStats && !hasActions && pr.elapsed == null) return null; + if (!hasStats && !hasActions && pr.size == null) return null; return (
@@ -416,9 +417,9 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) { ))}
)} - {pr.elapsed != null && ( - - {pr.elapsed} + {pr.size != null && ( + + )}
From dc79da0dab900e24dbc1980c95ab65e2a8545196 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 28 Jul 2026 14:04:58 -0400 Subject: [PATCH 4/4] Clean up size chip rendering and tests --- .../components/runs-list/run-table-row.tsx | 6 ++-- .../app/components/size-chip.test.tsx | 36 ++++++++++++++----- apps/fabro-web/app/components/size-chip.tsx | 5 +-- apps/fabro-web/app/routes/runs.tsx | 2 +- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/fabro-web/app/components/runs-list/run-table-row.tsx b/apps/fabro-web/app/components/runs-list/run-table-row.tsx index e7447bce5..5066bc9d8 100644 --- a/apps/fabro-web/app/components/runs-list/run-table-row.tsx +++ b/apps/fabro-web/app/components/runs-list/run-table-row.tsx @@ -115,11 +115,9 @@ export function RunTableRow({ )} {show("size") && ( - + {run.size != null && ( - - - + )} )} diff --git a/apps/fabro-web/app/components/size-chip.test.tsx b/apps/fabro-web/app/components/size-chip.test.tsx index b355f5db1..498c97f5e 100644 --- a/apps/fabro-web/app/components/size-chip.test.tsx +++ b/apps/fabro-web/app/components/size-chip.test.tsx @@ -1,25 +1,43 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import TestRenderer, { act } from "react-test-renderer"; +import { setupReactTestEnv } from "../lib/test-utils"; import { SizeChip } from "./size-chip"; import { Tooltip } from "./ui"; -function tooltipLabel(element: React.ReactElement): string { +let teardownReactTestEnv: (() => void) | undefined; +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function render(element: React.ReactElement): TestRenderer.ReactTestRenderer { let renderer: TestRenderer.ReactTestRenderer | undefined; act(() => { renderer = TestRenderer.create(element); }); - return renderer!.root.findByType(Tooltip).props.label as string; + mountedRenderers.push(renderer!); + return renderer!; +} + +function tooltipLabel(element: React.ReactElement): string { + return render(element).root.findByType(Tooltip).props.label as string; } describe("SizeChip", () => { - test("renders the size letter", () => { - let renderer: TestRenderer.ReactTestRenderer | undefined; - act(() => { - renderer = TestRenderer.create(); - }); + beforeEach(() => { + teardownReactTestEnv = setupReactTestEnv(); + }); - expect(JSON.stringify(renderer!.toJSON())).toContain("M"); + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + teardownReactTestEnv?.(); + teardownReactTestEnv = undefined; + }); + + test("renders the size letter", () => { + expect(JSON.stringify(render().toJSON())).toContain("M"); }); test("appends the cost to the tooltip", () => { diff --git a/apps/fabro-web/app/components/size-chip.tsx b/apps/fabro-web/app/components/size-chip.tsx index d23210955..7563b4c87 100644 --- a/apps/fabro-web/app/components/size-chip.tsx +++ b/apps/fabro-web/app/components/size-chip.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import type { RunSize } from "@qltysh/fabro-api-client"; import { formatUsdMicros } from "../lib/format"; @@ -11,7 +12,7 @@ const SIZE_TONE: Record = { XL: { className: "bg-coral/15 text-coral", note: "unhealthy" }, }; -export function SizeChip({ +export const SizeChip = memo(function SizeChip({ size, totalUsdMicros, }: { @@ -30,4 +31,4 @@ export function SizeChip({
); -} +}); diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index fe21e36c8..b1e09bf67 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -418,7 +418,7 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) { )} {pr.size != null && ( - + )}