Merge pull request #34994 from BerriAI/litellm_/cost-optimization-savings-pr-0a22b6

fix(cost-optimization): backport the savings chart axis fix and methodology popovers to rc/1.94.0
This commit is contained in:
yuneng-jiang 2026-07-28 12:55:28 -07:00 committed by GitHub
commit 7880e61200
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 381 additions and 69 deletions

View file

@ -1,5 +1,6 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolSpendResponse } from "@/components/networking";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
@ -80,13 +81,20 @@ const day = (date: string, metrics: Partial<SpendMetrics>): DailyData => ({
},
});
const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => {
interface RenderOptions {
toolSpend?: ToolSpendResponse;
from?: Date;
to?: Date;
}
const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
const { toolSpend = emptyToolSpend, from = new Date(2026, 6, 1), to = new Date(2026, 6, 14) } = options;
mockGetToolSpend.mockResolvedValue(toolSpend);
return render(
<UsageTab
accessToken="test-token"
activity={{
dateValue: { from: new Date("2026-07-01"), to: new Date("2026-07-14") },
dateValue: { from, to },
onDateChange: vi.fn(),
results,
loading: false,
@ -96,7 +104,13 @@ const renderWith = (results: DailyData[], toolSpend = emptyToolSpend) => {
);
};
const readSeries = (element: HTMLElement) => JSON.parse(element.getAttribute("data-series") ?? "[]");
describe("UsageTab", () => {
beforeEach(() => {
mockGetToolSpend.mockReset();
});
it("sums compression and caching dollars across days into the summary cards", () => {
const { getByText } = renderWith([
day("2026-07-12", {
@ -117,16 +131,88 @@ describe("UsageTab", () => {
expect(getByText("140,000 tokens compressed")).toBeInTheDocument();
});
it("builds a per-day time series and per-driver donut from the daily rows", () => {
const { getByTestId } = renderWith([
day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }),
day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }),
]);
const twoDays = () => [
day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }),
day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }),
];
const series = JSON.parse(getByTestId("area-chart").getAttribute("data-series") ?? "[]");
it("opens on a running total anchored at $0 at the start of the range", () => {
const { getByTestId } = renderWith(twoDays());
// Cumulative prepends a synthetic $0 point at the range start (Jul 1) so the
// line rises from zero rather than floating; the daily running totals follow.
const series = readSeries(getByTestId("area-chart"));
expect(series).toHaveLength(3);
expect(series[0]).toMatchObject({ date: "Jul 1", Compression: 0, "Prompt caching": 0 });
expect(series[1]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 });
expect(series[2].Compression).toBeCloseTo(0.14, 5);
expect(series[2]["Prompt caching"]).toBeCloseTo(0.016, 5);
});
it("rises from $0 to the day's cumulative total for a single-day range", () => {
// The original complaint: a one-day range plotted a single floating dot. The
// synthetic start anchor gives the line a zero origin to climb from.
const oneDay = new Date(2026, 6, 24);
const { getByTestId } = renderWith(
[day("2026-07-24", { compression_savings_spend: 0.2, prompt_caching_savings_spend: 0.05 })],
{ from: oneDay, to: oneDay },
);
const series = readSeries(getByTestId("area-chart"));
expect(series).toHaveLength(2);
expect(series[0]).toMatchObject({ date: "Jul 24", Compression: 0, "Prompt caching": 0 });
expect(series[1]).toMatchObject({ date: "Jul 24", Compression: 0.2, "Prompt caching": 0.05 });
});
it("plots the daily series oldest first even though the rollup arrives newest first", async () => {
// The daily activity endpoint returns days newest first; the chart must
// still read left to right in time, and the running total must climb toward
// the newest day, not fall away from it.
const newestFirst = [
day("2026-07-13", { prompt_caching_savings_spend: 0.1 }),
day("2026-07-12", { prompt_caching_savings_spend: 0.04 }),
];
const { getByTestId, getByRole } = renderWith(newestFirst);
// The $0 anchor leads, then the days climb oldest to newest.
const cumulative = readSeries(getByTestId("area-chart"));
expect(cumulative.map((p: { date: string }) => p.date)).toEqual(["Jul 1", "Jul 12", "Jul 13"]);
expect(cumulative[1]["Prompt caching"]).toBeCloseTo(0.04, 5);
expect(cumulative[2]["Prompt caching"]).toBeCloseTo(0.14, 5);
expect(cumulative[2]["Prompt caching"]).toBeGreaterThan(cumulative[1]["Prompt caching"]);
await userEvent.click(getByRole("tab", { name: "Per day" }));
const perDay = readSeries(getByTestId("bar-chart"));
expect(perDay.map((p: { date: string }) => p.date)).toEqual(["Jul 12", "Jul 13"]);
});
it("draws bars of the raw per-interval readings on the other tab", async () => {
const { getByRole, getByTestId, queryByTestId } = renderWith(twoDays());
// Cumulative opens on the area line.
expect(getByTestId("area-chart")).toBeInTheDocument();
await userEvent.click(getByRole("tab", { name: "Per day" }));
// Per day switches to a bar chart of the unaccumulated daily savings, with no
// synthetic anchor prepended.
expect(queryByTestId("area-chart")).toBeNull();
const series = readSeries(getByTestId("bar-chart"));
expect(series).toHaveLength(2);
expect(series[0]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 });
expect(series[1]).toMatchObject({ Compression: 0.1, "Prompt caching": 0.01 });
});
it("says what the line means and over what range", async () => {
const { getByText, getByRole } = renderWith(twoDays());
expect(getByText("Running total saved · Jul 1 Jul 14")).toBeInTheDocument();
await userEvent.click(getByRole("tab", { name: "Per day" }));
expect(getByText("Saved per day · Jul 1 Jul 14")).toBeInTheDocument();
});
it("builds the per-driver donut from the range totals, not the running total", () => {
const { getByTestId } = renderWith(twoDays());
const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]");
expect(slices).toEqual([
@ -152,7 +238,7 @@ describe("UsageTab", () => {
start_date: "2026-07-12",
end_date: "2026-07-12",
};
const { findAllByTestId } = renderWith([day("2026-07-12", {})], toolSpend);
const { findAllByTestId } = renderWith([day("2026-07-12", {})], { toolSpend });
const bars = await findAllByTestId("bar-chart");
const series = JSON.parse(bars[0].getAttribute("data-series") ?? "[]");
@ -172,7 +258,7 @@ describe("UsageTab", () => {
start_date: "2026-07-12",
end_date: "2026-07-12",
};
const { findAllByTestId, getAllByTestId } = renderWith([day("2026-07-12", {})], toolSpend);
const { findAllByTestId, getAllByTestId } = renderWith([day("2026-07-12", {})], { toolSpend });
const bars = await findAllByTestId("bar-chart");
const [totalByTool, dailyByTool] = bars.slice(-2);

View file

@ -1,15 +1,29 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Collapse } from "antd";
import { Info } from "lucide-react";
import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getToolSpend, ToolSpendResponse } from "@/components/networking";
import { SpendMetrics } from "@/components/UsagePage/types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { buildDailyToolSeries, topToolsBySpend, usd } from "./costOptimizationUtils";
import {
buildDailyToolSeries,
formatRangeLabel,
localIsoDay,
MAX_POINTS_WITH_DOTS,
SAVINGS_SERIES,
SavingsAccumulation,
SavingsPoint,
toCumulative,
topToolsBySpend,
usd,
withStartAnchor,
} from "./costOptimizationUtils";
import { DailyActivityRange } from "./useDailyActivityRange";
interface UsageTabProps {
@ -24,6 +38,8 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = {
end_date: null,
};
const SAVINGS_COLORS = ["emerald", "blue"] as const;
const shortDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
@ -33,45 +49,24 @@ const compressionOf = (m: SpendMetrics): number => m.compression_savings_spend ?
const cachingOf = (m: SpendMetrics): number => m.prompt_caching_savings_spend ?? 0;
const savedTokensOf = (m: SpendMetrics): number => m.compression_saved_tokens ?? 0;
const MethodologyNote = () => (
<Collapse
ghost
items={[
{
key: "methodology",
label: <span className="text-sm font-medium">How savings are calculated</span>,
children: (
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Savings are computed for each request when it is logged, using the provider&apos;s reported usage and the
model&apos;s pricing, then summed into a daily rollup. Totals below are read from that rollup over the
selected date range, so the numbers never require a scan of raw request logs.
</p>
<p>
Compression savings are the tokens Headroom removed before the call, priced at the model&apos;s input
rate: <code>compression_saved_tokens * input_cost_per_token</code>
</p>
<p>
Prompt caching savings are the tokens the provider served from cache (Anthropic{" "}
<code>cache_read_input_tokens</code>, or OpenAI-style <code>prompt_tokens_details.cached_tokens</code>),
priced at the discount between the normal input rate and the cache-read rate:{" "}
<code>cache_read_input_tokens * max(input_cost_per_token - cache_read_input_token_cost, 0)</code>
</p>
<p>
Total saved is the sum of both drivers. Models without a separate cache-read price in the pricing map
contribute zero caching savings rather than erroring.
</p>
</div>
),
},
]}
/>
);
const SummaryCard = ({ label, value, hint }: { label: string; value: string; hint?: string }) => (
const SummaryCard = ({ label, value, hint, info }: { label: string; value: string; hint?: string; info?: string }) => (
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
{info && (
<Popover>
<PopoverTrigger
aria-label={`How ${label.toLowerCase()} is calculated`}
data-testid={`summary-card-info-${label.toLowerCase().replace(/\s+/g, "-")}`}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<Info className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="end" className="w-64 text-sm text-muted-foreground">
{info}
</PopoverContent>
</Popover>
)}
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-foreground">{value}</p>
@ -113,16 +108,41 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const savedTokensTotal = useMemo(() => results.reduce((sum, d) => sum + savedTokensOf(d.metrics), 0), [results]);
const totalSaved = compressionTotal + cachingTotal;
const overTime = useMemo(
const [accumulation, setAccumulation] = useState<SavingsAccumulation>("cumulative");
// The daily rollup arrives newest first; sort on the raw ISO date so the axis
// reads oldest to newest and the running total accumulates forward in time
// rather than backward. Sort here, before shortDate() drops the year and makes
// the labels unsortable.
const perInterval = useMemo<SavingsPoint[]>(
() =>
results.map((d) => ({
date: shortDate(d.date),
Compression: compressionOf(d.metrics),
"Prompt caching": cachingOf(d.metrics),
})),
[...results]
.sort((a, b) => a.date.localeCompare(b.date))
.map((d) => ({
date: shortDate(d.date),
Compression: compressionOf(d.metrics),
"Prompt caching": cachingOf(d.metrics),
})),
[results],
);
// Cumulative anchors on a synthetic $0 point at the range start so a short
// range (down to a single day) rises from zero instead of floating as one dot.
const overTime = useMemo(() => {
if (accumulation !== "cumulative") return perInterval;
const startLabel = startTime ? shortDate(localIsoDay(startTime)) : "";
return withStartAnchor(toCumulative(perInterval), startLabel);
}, [accumulation, perInterval, startTime]);
const intervalLabel = "Per day";
const rangeLabel = formatRangeLabel(startTime ?? undefined, endTime ?? undefined);
const savingsSubtitle = [
accumulation === "cumulative" ? "Running total saved" : `Saved ${intervalLabel.toLowerCase()}`,
rangeLabel,
]
.filter(Boolean)
.join(" \u00b7 ");
const byDriver = useMemo(
() =>
[
@ -150,8 +170,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
return (
<div className="w-full space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<MethodologyNote />
<div className="flex flex-wrap items-center justify-end gap-4">
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
@ -165,23 +184,57 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
label="Compression savings"
value={usd(compressionTotal)}
hint={`${formatNumberWithCommas(savedTokensTotal)} tokens compressed`}
info="Tokens Headroom removed before the call, priced at the model's input rate."
/>
<SummaryCard
label="Prompt caching savings"
value={usd(cachingTotal)}
hint="Cache read discount"
info="Tokens the provider served from cache, priced at the discount between the input and cache-read rates."
/>
<SummaryCard label="Prompt caching savings" value={usd(cachingTotal)} hint="Cache read discount" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Savings over time</CardTitle>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Savings</CardTitle>
<p className="text-sm text-muted-foreground">{savingsSubtitle}</p>
</div>
<div className="flex 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>
</CardHeader>
<CardContent>
<AreaChart
data={overTime}
index="date"
categories={["Compression", "Prompt caching"]}
colors={["emerald", "blue"]}
valueFormatter={usd}
/>
{accumulation === "cumulative" ? (
<AreaChart
data={overTime}
index="date"
categories={SAVINGS_SERIES}
colors={SAVINGS_COLORS}
valueFormatter={usd}
showLegend={false}
showDots={overTime.length <= MAX_POINTS_WITH_DOTS}
/>
) : (
<BarChart
data={overTime}
index="date"
categories={SAVINGS_SERIES}
colors={SAVINGS_COLORS}
stack
valueFormatter={usd}
showLegend={false}
/>
)}
</CardContent>
</Card>
<Card>

View file

@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import type { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking";
import { buildDailyToolSeries, computeCacheLeakage, isAnthropicModel, topToolsBySpend } from "./costOptimizationUtils";
import {
buildDailyToolSeries,
computeCacheLeakage,
formatRangeLabel,
isAnthropicModel,
localIsoDay,
toCumulative,
topToolsBySpend,
withStartAnchor,
} from "./costOptimizationUtils";
const metrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
spend: 0,
@ -221,3 +230,79 @@ describe("topToolsBySpend", () => {
expect(topToolsBySpend(byTool, 2).map((t) => t.tool_name)).toEqual(["b", "c"]);
});
});
describe("localIsoDay", () => {
it("reads the date off the viewer's clock rather than shifting it to UTC", () => {
expect(localIsoDay(new Date(2026, 6, 23, 23, 30))).toBe("2026-07-23");
expect(localIsoDay(new Date(2026, 0, 5, 0, 30))).toBe("2026-01-05");
});
});
describe("toCumulative", () => {
const point = (date: string, compression: number, caching: number) => ({
date,
Compression: compression,
"Prompt caching": caching,
});
it("turns each reading into everything saved up to that point", () => {
const running = toCumulative([point("Jul 1", 1, 10), point("Jul 2", 2, 20), point("Jul 3", 3, 30)]);
expect(running.map((p) => p.Compression)).toEqual([1, 3, 6]);
expect(running.map((p) => p["Prompt caching"])).toEqual([10, 30, 60]);
});
it("accumulates each driver on its own, so one flat series cannot lift the other", () => {
const running = toCumulative([point("Jul 1", 0, 5), point("Jul 2", 0, 5)]);
expect(running.map((p) => p.Compression)).toEqual([0, 0]);
expect(running.map((p) => p["Prompt caching"])).toEqual([5, 10]);
});
it("never falls, even across a quiet interval", () => {
const running = toCumulative([point("Jul 1", 4, 0), point("Jul 2", 0, 0), point("Jul 3", 1, 0)]);
expect(running.map((p) => p.Compression)).toEqual([4, 4, 5]);
});
it("keeps the labels and length of the readings it was given", () => {
const running = toCumulative([point("9am", 1, 1), point("10am", 1, 1)]);
expect(running.map((p) => p.date)).toEqual(["9am", "10am"]);
expect(toCumulative([])).toEqual([]);
});
});
describe("withStartAnchor", () => {
const point = (date: string, compression: number, caching: number) => ({
date,
Compression: compression,
"Prompt caching": caching,
});
it("lifts a single-day cumulative off a floating dot by prepending a $0 origin", () => {
const anchored = withStartAnchor([point("Jul 24", 12, 30)], "Jul 24");
expect(anchored).toEqual([point("Jul 24", 0, 0), point("Jul 24", 12, 30)]);
});
it("starts the range at zero without disturbing the running totals that follow", () => {
const anchored = withStartAnchor([point("Jul 16", 5, 1), point("Jul 17", 9, 4)], "Jul 16");
expect(anchored.map((p) => p.Compression)).toEqual([0, 5, 9]);
expect(anchored.map((p) => p["Prompt caching"])).toEqual([0, 1, 4]);
});
it("leaves an empty series alone so the chart's own no-data state can show", () => {
expect(withStartAnchor([], "Jul 24")).toEqual([]);
});
});
describe("formatRangeLabel", () => {
it("reads as a range across days", () => {
expect(formatRangeLabel(new Date(2026, 6, 16), new Date(2026, 6, 23))).toBe("Jul 16 \u2013 Jul 23");
});
it("collapses to one date when both ends are the same day", () => {
expect(formatRangeLabel(new Date(2026, 6, 23), new Date(2026, 6, 23))).toBe("Jul 23");
});
it("is empty until both ends are picked", () => {
expect(formatRangeLabel(undefined, new Date(2026, 6, 23))).toBe("");
expect(formatRangeLabel(new Date(2026, 6, 23), undefined)).toBe("");
});
});

View file

@ -149,3 +149,63 @@ const seedPoint = (date: string, toolNames: readonly string[]): DailyToolSpendPo
export const topToolsBySpend = (byTool: readonly ToolSpendEntry[], limit = 8): ToolSpendEntry[] =>
[...byTool].sort((a, b) => b.spend - a.spend).slice(0, limit);
export const localIsoDay = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
export type SavingsAccumulation = "cumulative" | "per-interval";
// A type alias, not an interface: only aliases get the implicit index signature
// that the chart wrappers' `Record<string, unknown>` datum bound requires.
export type SavingsPoint = {
date: string;
Compression: number;
"Prompt caching": number;
};
export const SAVINGS_SERIES = ["Compression", "Prompt caching"] as const;
/**
* Running total of each series across the selected window. The total restarts
* at the beginning of the range rather than carrying in earlier spend, which is
* what "running total saved, <range>" claims on the card.
*/
export const toCumulative = (points: readonly SavingsPoint[]): SavingsPoint[] =>
points.reduce<SavingsPoint[]>((acc, point) => {
const previous = acc[acc.length - 1];
return [
...acc,
{
date: point.date,
Compression: (previous?.Compression ?? 0) + point.Compression,
"Prompt caching": (previous?.["Prompt caching"] ?? 0) + point["Prompt caching"],
},
];
}, []);
/**
* Prepend a synthetic $0 point at the start of the range so the cumulative line
* rises from zero instead of floating as a single dot. The daily rollup only
* resolves whole days, so a one-day range would otherwise be one point; with the
* anchor it reads as "start of range $0 climbing to the range's running total".
* An empty series is left untouched so the chart's own "No data" state shows.
*/
export const withStartAnchor = (cumulative: readonly SavingsPoint[], startLabel: string): SavingsPoint[] =>
cumulative.length === 0
? [...cumulative]
: [{ date: startLabel, Compression: 0, "Prompt caching": 0 }, ...cumulative];
/** "Jul 16 Jul 23", collapsing to a single date when the range is one day. */
export const formatRangeLabel = (from: Date | undefined, to: Date | undefined): string => {
if (!from || !to) return "";
const short = (d: Date) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const start = short(from);
const end = short(to);
return start === end ? start : `${start} ${end}`;
};
/**
* Dots mark each reading, as in the design. Past this many readings they crowd
* into a solid band and stop being readable, so the line carries it alone.
*/
export const MAX_POINTS_WITH_DOTS = 31;

View file

@ -40,4 +40,12 @@ describe("AreaChart", () => {
expect(area.getAttribute("fill")).toMatch(/^url\(#fill-/);
}
});
it("marks each reading with a dot only when asked", () => {
const withoutDots = render(<AreaChart data={data} index="date" categories={["tokens"]} />);
expect(withoutDots.container.querySelectorAll("circle.recharts-dot")).toHaveLength(0);
const withDots = render(<AreaChart data={data} index="date" categories={["tokens"]} showDots />);
expect(withDots.container.querySelectorAll("circle.recharts-dot").length).toBeGreaterThanOrEqual(data.length);
});
});

View file

@ -17,6 +17,7 @@ export type AreaChartProps<TDatum extends Record<string, unknown>> = {
showLegend?: boolean;
showGridLines?: boolean;
showTooltip?: boolean;
showDots?: boolean;
customTooltip?: ChartTooltipComponent;
className?: string;
style?: React.CSSProperties;
@ -32,6 +33,7 @@ export function AreaChart<TDatum extends Record<string, unknown>>({
showLegend = true,
showGridLines = true,
showTooltip = true,
showDots = false,
customTooltip,
className,
style,
@ -94,7 +96,7 @@ export function AreaChart<TDatum extends Record<string, unknown>>({
strokeWidth={2}
fill={`url(#fill-${gradientId}-${i})`}
fillOpacity={1}
dot={false}
dot={showDots ? { r: 3.5, strokeWidth: 2, stroke: fills[i], fill: "var(--background, #fff)" } : false}
isAnimationActive={false}
/>
))}

View file

@ -20,6 +20,13 @@ describe("BarChart", () => {
expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"]));
});
it("renders the No data placeholder instead of a chart when data is empty", () => {
const { container, getByText } = render(<BarChart data={[]} index="date" categories={["passed"]} />);
expect(getByText("No data")).toBeTruthy();
expect(container.querySelector('[data-slot="chart"]')).toBeNull();
});
it("falls back to the tremor default color cycle when no colors are passed", () => {
const { container } = render(<BarChart data={data} index="date" categories={["passed", "blocked"]} />);

View file

@ -50,6 +50,17 @@ export function BarChart<TDatum extends Record<string, unknown>>({
className,
style,
}: BarChartProps<TDatum>) {
if (data.length === 0) {
return (
<div
className={cn("flex h-80 w-full items-center justify-center rounded-lg border border-dashed", className)}
style={style}
>
<p className="text-sm text-muted-foreground">No data</p>
</div>
);
}
const fills = categoryFills(colorByDatum ? data.length : categories.length, colors);
const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }]));
const vertical = layout === "vertical";