mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(cost-optimization): anchor the savings line at a $0 range start
The "Savings over time" chart plotted a single floating dot for short
ranges: the daily rollup keys spend by YYYY-MM-DD, so a one-day range is
one point by construction. Rather than stand up an hourly SpendLogs data
source, read that same daily rollup and make the cumulative line legible.
- Cumulative | Per day toggle. Cumulative accumulates within the range;
Per day shows the raw stacked bars.
- Cumulative prepends a synthetic $0 point at the range start
(withStartAnchor) so the line rises from zero to the running total
instead of floating. An empty series is left untouched so the chart's
own "No data" state shows.
- Order the daily series oldest-first (the rollup arrives newest-first)
so the axis reads left to right and the total accumulates forward.
- Header legend, dots on small series, and a "No data" guard on BarChart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 1fa40bd168)
This commit is contained in:
parent
3322f24783
commit
99618fb392
8 changed files with 355 additions and 28 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -7,10 +7,23 @@ import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP }
|
|||
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 {
|
||||
|
|
@ -25,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" });
|
||||
|
||||
|
|
@ -93,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(
|
||||
() =>
|
||||
[
|
||||
|
|
@ -157,16 +197,44 @@ 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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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"]} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue