fix(ui): keep the savings header one shape across both tabs

Title, legend, toggle and subtitle all shared a row. The subtitle is longer on
Cumulative than on Per day, so it wrapped on one tab and not the other, growing
the header by a line and shifting the legend, the toggle and the chart with it.

The title and the controls now hold a fixed row and the subtitle sits on its own
line beneath, so nothing above the chart depends on how long that text is.

The test that guards this selected the subtitle by class and matched a summary
card's hint instead, which made it pass with the subtitle moved back into the
row. It now finds the element by its text.
This commit is contained in:
Tin Chi Lo 2026-07-31 23:05:03 -07:00
parent e9b4f956c0
commit 669fd84aee
2 changed files with 39 additions and 30 deletions

View file

@ -249,31 +249,40 @@ describe("UsageTab", () => {
expect(readSeries(bars)[0]).toMatchObject({ "Auto-router": -0.05 });
});
it("keeps the legend and the accumulation toggle in one place across both tabs", async () => {
// The subtitle is longer on Cumulative ("Running total saved") than on Per day
// ("Saved per day"). With a wrapping header the extra width pushed the legend and
// the toggle onto a second row, so they jumped whenever the tab changed.
it("keeps the header the same shape on both tabs so the chart cannot shift", async () => {
// The subtitle differs in length between the tabs ("Running total saved" vs "Saved
// per day"). Sharing a row with the title and controls made the header grow a line
// when it wrapped, moving the legend, the toggle and the chart below it.
const { getByRole, getByTestId, container } = renderWith(twoDays());
const controlsOn = () => {
const layout = () => {
const legend = getByTestId("chart-legend");
const controls = legend.parentElement as HTMLElement;
// the toggle lives in the same box as the legend, so neither can move alone
// the toggle travels with the legend, so neither can move independently
expect(controls.contains(getByRole("tablist"))).toBe(true);
return controls;
const titleRow = controls.parentElement as HTMLElement;
// by text, not by class: several cards render a muted <p>, and grabbing the first
// one silently asserts against a summary-card hint instead of this subtitle
const subtitle = Array.from(container.querySelectorAll("p")).find((el) =>
/Running total saved|Saved per day/.test(el.textContent ?? ""),
);
expect(subtitle, "savings subtitle should be rendered").toBeTruthy();
return { controls, titleRow, subtitle: subtitle as HTMLElement };
};
const cumulative = controlsOn();
expect(cumulative.className).toContain("shrink-0");
const header = cumulative.parentElement as HTMLElement;
expect(header.className).not.toContain("flex-wrap");
const before = layout();
// the subtitle is a sibling BELOW the title row, never inside it, so its length
// cannot change that row's height
expect(before.titleRow.contains(before.subtitle)).toBe(false);
expect(before.titleRow.className).not.toContain("flex-wrap");
expect(before.controls.className).toContain("shrink-0");
await userEvent.click(getByRole("tab", { name: "Per day" }));
// same container, same classes, after the subtitle changed length
expect(controlsOn()).toBe(cumulative);
expect((cumulative.parentElement as HTMLElement).className).not.toContain("flex-wrap");
const after = layout();
expect(after.controls).toBe(before.controls);
expect(after.titleRow).toBe(before.titleRow);
expect(after.titleRow.contains(after.subtitle)).toBe(false);
expect(container.textContent).toContain("Saved per day");
});

View file

@ -210,23 +210,23 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
{/* CardHeader is the row itself, matching SummaryCard: an inner wrapper that
wrapped on overflow moved the legend and the tab control onto a second line
whenever the subtitle grew, so they jumped between the two tabs */}
<CardHeader className="flex flex-row items-start justify-between space-y-0 gap-4">
<div className="space-y-1.5">
{/* Title and controls share a fixed row; the subtitle gets its own line below.
Competing for one row made the header taller whenever the subtitle wrapped,
which differs between the two tabs, so the chart shifted down on one of them */}
<CardHeader className="space-y-1.5">
<div className="flex flex-row items-center justify-between gap-4">
<CardTitle>Savings</CardTitle>
<p className="text-sm text-muted-foreground">{savingsSubtitle}</p>
</div>
<div className="flex shrink-0 items-center gap-4">
<CustomLegend categories={SAVINGS_SERIES} colors={SAVINGS_COLORS} />
<Tabs value={accumulation} onValueChange={(value) => setAccumulation(value as SavingsAccumulation)}>
<TabsList>
<TabsTrigger value="cumulative">Cumulative</TabsTrigger>
<TabsTrigger value="per-interval">{intervalLabel}</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex shrink-0 items-center gap-4">
<CustomLegend categories={SAVINGS_SERIES} colors={SAVINGS_COLORS} />
<Tabs value={accumulation} onValueChange={(value) => setAccumulation(value as SavingsAccumulation)}>
<TabsList>
<TabsTrigger value="cumulative">Cumulative</TabsTrigger>
<TabsTrigger value="per-interval">{intervalLabel}</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<p className="text-sm text-muted-foreground">{savingsSubtitle}</p>
</CardHeader>
<CardContent>
{accumulation === "cumulative" ? (