Skip to content

Commit b02a48a

Browse files
committed
fix(models): improve model colors and sort selector
1 parent b2093ac commit b02a48a

3 files changed

Lines changed: 82 additions & 15 deletions

File tree

src/components/model-usage-card.test.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import { render, screen, within } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
5-
import { describe, expect, it } from "vitest";
5+
import { beforeAll, describe, expect, it, vi } from "vitest";
66
import { ModelUsageCard } from "@/components/model-usage-card";
77
import type { OverviewResponse } from "@/lib/api";
8-
import { buildDonutData, priceTones, sortModels, tokenBreakdown } from "@/lib/model-analytics";
8+
import { buildDonutData, modelPageColors, OTHER_MODEL_COLOR, priceTones, sortModels, tokenBreakdown } from "@/lib/model-analytics";
99
import i18n from "@/i18n";
1010

1111
type Model = OverviewResponse["models"][number];
@@ -24,6 +24,10 @@ const model = (name: string, totalTokens: number, overrides: Partial<Model> = {}
2424
...overrides,
2525
});
2626

27+
beforeAll(() => {
28+
window.HTMLElement.prototype.scrollIntoView = vi.fn();
29+
});
30+
2731
describe("model analytics", () => {
2832
it("does not double count cached input in token composition", () => {
2933
const parts = tokenBreakdown(model("one", 1_200, { inputTokens: 1_000, cachedInputTokens: 400, outputTokens: 200 }));
@@ -35,7 +39,26 @@ describe("model analytics", () => {
3539
const data = buildDonutData(Array.from({ length: 8 }, (_, index) => model(`m${index + 1}`, 800 - index * 100)), "Other");
3640
expect(data).toHaveLength(7);
3741
expect(data.slice(0, 6).map((item) => item.value)).toEqual([800, 700, 600, 500, 400, 300]);
38-
expect(data[6]).toMatchObject({ name: "Other", value: 300 });
42+
expect(data[6]).toMatchObject({ name: "Other", value: 300, color: OTHER_MODEL_COLOR });
43+
});
44+
45+
it("assigns the first six models unique colors in deterministic token order", () => {
46+
const rows = [
47+
model("zeta", 100),
48+
model("beta", 300),
49+
model("alpha", 300),
50+
model("gamma", 200),
51+
model("delta", 150),
52+
model("epsilon", 125),
53+
];
54+
const colors = modelPageColors(rows);
55+
const rankedNames = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"];
56+
57+
expect([...colors.keys()]).toEqual(rankedNames);
58+
expect(new Set(rankedNames.map((name) => colors.get(name))).size).toBe(6);
59+
expect(buildDonutData(rows, "Other", colors).map((entry) => entry.color)).toEqual(
60+
rankedNames.map((name) => colors.get(name)),
61+
);
3962
});
4063

4164
it("sorts descending by tokens, cost, and effective price with unknown prices last", () => {
@@ -64,8 +87,29 @@ describe("model analytics", () => {
6487
expect(within(row).getAllByText("Pricing unavailable")).toHaveLength(4);
6588
expect(screen.getByText("Token composition")).toBeInTheDocument();
6689

67-
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Sort descending" }), "effective");
68-
expect(screen.getByRole("combobox")).toHaveValue("effective");
90+
const user = userEvent.setup();
91+
screen.getByRole("combobox", { name: "Sort descending" }).focus();
92+
await user.keyboard("[Enter][End][Enter]");
93+
expect(screen.getByRole("combobox", { name: "Sort descending" })).toHaveTextContent("Effective price");
94+
});
95+
96+
it("uses one model color mapping across the chart and table while sorting", async () => {
97+
const rows = [model("alpha", 100, { costUSD: 9 }), model("beta", 300, { costUSD: 1 })];
98+
const colors = modelPageColors(rows);
99+
render(<ModelUsageCard models={rows} />);
100+
101+
for (const row of rows) {
102+
const tableRow = document.querySelector(`[data-model-row='${row.model}']`) as HTMLElement;
103+
expect(tableRow).toHaveAttribute("data-model-color", colors.get(row.model));
104+
expect(document.querySelector(`[data-model-legend='${row.model}']`)).toHaveAttribute("data-model-color", colors.get(row.model));
105+
expect(tableRow.querySelector("[data-model-badge]")).toHaveClass("text-foreground");
106+
}
107+
108+
const user = userEvent.setup();
109+
screen.getByRole("combobox", { name: "Sort descending" }).focus();
110+
await user.keyboard("[Enter][ArrowDown][Enter]");
111+
expect(document.querySelector("tbody tr")?.getAttribute("data-model-row")).toBe("alpha");
112+
expect(document.querySelector("[data-model-row='beta']")).toHaveAttribute("data-model-color", colors.get("beta"));
69113
});
70114

71115
it("renders the model page labels in Chinese", async () => {

src/components/model-usage-card.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useMemo, useState } from "react";
22
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
33
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
45
import type { OverviewResponse } from "@/lib/api";
56
import {
67
buildDonutData,
7-
modelTone,
8+
modelPageColors,
89
priceTones,
910
sortModels,
1011
tokenBreakdown,
@@ -57,7 +58,8 @@ export function ModelUsageCard({ models }: ModelUsageCardProps) {
5758
costUSD: result.costUSD + model.costUSD,
5859
}), { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, totalTokens: 0, costUSD: 0 }), [models]);
5960
const sortedModels = useMemo(() => sortModels(models, sort), [models, sort]);
60-
const donutData = useMemo(() => buildDonutData(models, t("models.other")), [models, t]);
61+
const modelColors = useMemo(() => modelPageColors(models), [models]);
62+
const donutData = useMemo(() => buildDonutData(models, t("models.other"), modelColors), [modelColors, models, t]);
6163
const tones = useMemo(() => ({
6264
input: priceTones(models, "inputCostPerMillionTokens"),
6365
cached: priceTones(models, "cachedInputCostPerMillionTokens"),
@@ -119,7 +121,7 @@ export function ModelUsageCard({ models }: ModelUsageCardProps) {
119121
</ResponsiveContainer>
120122
</div>
121123
<div className="grid w-full gap-2 text-xs sm:w-1/2">
122-
{donutData.map((entry) => <div key={entry.name} className="flex items-center justify-between gap-3"><span className="min-w-0 truncate"><i className="mr-2 inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: entry.color }} />{entry.name}</span><span className="tabular-nums text-muted-foreground">{formatPercent(entry.value / Math.max(totals.totalTokens, 1))}</span></div>)}
124+
{donutData.map((entry) => <div key={entry.name} data-model-legend={entry.name} data-model-color={entry.color} className="flex items-center justify-between gap-3"><span className="min-w-0 truncate"><i className="mr-2 inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: entry.color }} />{entry.name}</span><span className="tabular-nums text-muted-foreground">{formatPercent(entry.value / Math.max(totals.totalTokens, 1))}</span></div>)}
123125
</div>
124126
</CardContent>
125127
</Card>
@@ -128,7 +130,19 @@ export function ModelUsageCard({ models }: ModelUsageCardProps) {
128130
<Card>
129131
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
130132
<div><CardTitle>{t("models.comparison.title")}</CardTitle><CardDescription>{t("models.comparison.subtitle")}</CardDescription></div>
131-
<label className="flex items-center gap-2 text-xs text-muted-foreground">{t("models.sort.label")}<select aria-label={t("models.sort.label")} value={sort} onChange={(event) => setSort(event.target.value as ModelSort)} className="rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground"><option value="tokens">{t("models.sort.tokens")}</option><option value="cost">{t("models.sort.cost")}</option><option value="effective">{t("models.sort.effective")}</option></select></label>
133+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
134+
<span>{t("models.sort.label")}</span>
135+
<Select value={sort} onValueChange={(value) => setSort(value as ModelSort)}>
136+
<SelectTrigger aria-label={t("models.sort.label")} className="w-[180px]">
137+
<SelectValue />
138+
</SelectTrigger>
139+
<SelectContent>
140+
<SelectItem value="tokens">{t("models.sort.tokens")}</SelectItem>
141+
<SelectItem value="cost">{t("models.sort.cost")}</SelectItem>
142+
<SelectItem value="effective">{t("models.sort.effective")}</SelectItem>
143+
</SelectContent>
144+
</Select>
145+
</div>
132146
</CardHeader>
133147
<CardContent>
134148
<div className="mb-4 flex flex-wrap gap-3 text-[11px] text-muted-foreground" aria-label={t("models.price_legend.label")}>
@@ -138,12 +152,12 @@ export function ModelUsageCard({ models }: ModelUsageCardProps) {
138152
<table className="min-w-[1120px] w-full border-separate border-spacing-0 text-sm">
139153
<thead><tr className="text-left text-[10px] uppercase tracking-[0.12em] text-muted-foreground"><th className="border-b border-border pb-2">{t("models.groups.model")}</th><th className="border-b border-border px-4 pb-2">{t("models.groups.tokens")}</th><th className="border-b border-border px-4 pb-2">{t("models.groups.prices")}</th><th className="border-b border-border pb-2">{t("models.groups.cost")}</th></tr></thead>
140154
<tbody>{sortedModels.map((model) => {
141-
const identity = modelTone(model.model);
155+
const modelColor = modelColors.get(model.model)!;
142156
const parts = tokenBreakdown(model);
143157
const usageShare = model.totalTokens / Math.max(totals.totalTokens, 1);
144158
const costShare = totals.costUSD > 0 ? model.costUSD / totals.costUSD : 0;
145-
return <tr key={model.model} data-model-row={model.model} className="align-top">
146-
<td className="border-b border-border/70 py-4 pr-4"><div className="flex items-center gap-2"><span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: identity.color }} /><span className={cn("rounded-full border px-2 py-0.5 font-semibold", identity.className)}>{model.model}</span></div><p className="mt-2 text-xs tabular-nums text-muted-foreground">{formatPercent(usageShare)} {t("models.of_usage")}</p></td>
159+
return <tr key={model.model} data-model-row={model.model} data-model-color={modelColor} className="align-top">
160+
<td className="border-b border-border/70 py-4 pr-4"><div className="flex items-center gap-2"><span data-model-swatch className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: modelColor }} /><span data-model-badge className="rounded-full border px-2 py-0.5 font-semibold text-foreground" style={{ borderColor: `${modelColor}66`, backgroundColor: `${modelColor}1a` }}>{model.model}</span></div><p className="mt-2 text-xs tabular-nums text-muted-foreground">{formatPercent(usageShare)} {t("models.of_usage")}</p></td>
147161
<td className="border-b border-border/70 px-4 py-4"><div className="mb-2 flex justify-between gap-4"><strong className="tabular-nums">{formatNumber(model.totalTokens)}</strong><span className="text-xs text-muted-foreground">{t("models.composition.input_total")} {formatNumber(model.inputTokens)} · {t("models.composition.cached")} {formatNumber(model.cachedInputTokens)} · {t("models.composition.output")} {formatNumber(model.outputTokens)}</span></div><TokenBar row={model} label={t("models.composition.bar_label", { input: formatNumber(parts.nonCachedInput), cached: formatNumber(parts.cachedInput), output: formatNumber(parts.output), total: formatNumber(model.totalTokens) })} /></td>
148162
<td className="border-b border-border/70 px-4 py-4"><div className="grid grid-cols-4 gap-3 text-xs"><div title={t("models.prices.input_tooltip")}><p className="mb-1 text-muted-foreground">{t("models.prices.input")}</p><Price value={model.inputCostPerMillionTokens} tone={tones.input.get(model.model)!} unavailable={unavailable} /></div><div title={t("models.prices.cached_tooltip")}><p className="mb-1 text-muted-foreground">{t("models.prices.cached")}</p><Price value={model.cachedInputCostPerMillionTokens} tone={tones.cached.get(model.model)!} unavailable={unavailable} /></div><div title={t("models.prices.output_tooltip")}><p className="mb-1 text-muted-foreground">{t("models.prices.output")}</p><Price value={model.outputCostPerMillionTokens} tone={tones.output.get(model.model)!} unavailable={unavailable} /></div><div title={t("models.prices.effective_tooltip")}><p className="mb-1 text-muted-foreground">{t("models.prices.effective")}</p><Price value={model.effectiveCostPerMillionTokens} tone={tones.effective.get(model.model)!} unavailable={unavailable} /></div></div></td>
149163
<td className="border-b border-border/70 py-4 text-right"><p data-effective-tone={tones.effective.get(model.model)} className={cn("font-bold tabular-nums", priceToneClasses[tones.effective.get(model.model)!])}>{formatCurrency(model.costUSD)}</p><p className="mt-1 text-xs tabular-nums text-muted-foreground">{formatPercent(costShare)} {t("models.of_cost")}</p></td>

src/lib/model-analytics.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export type ModelSort = "tokens" | "cost" | "effective";
55
export type PriceTone = "low" | "medium" | "high" | "equal" | "unavailable";
66

77
const MODEL_COLORS = ["#0ea5e9", "#8b5cf6", "#06b6d4", "#d946ef", "#f97316", "#14b8a6"] as const;
8+
const MODEL_PAGE_COLORS = ["#2563eb", "#d97706", "#16a34a", "#c026d3", "#dc2626", "#0891b2"] as const;
9+
export const OTHER_MODEL_COLOR = "#94a3b8";
810
const MODEL_TONE_CLASSES = [
911
"border-sky-500/20 bg-sky-500/10 text-sky-500",
1012
"border-violet-500/20 bg-violet-500/10 text-violet-500",
@@ -21,6 +23,13 @@ export function modelTone(model: string) {
2123
return { index, color: MODEL_COLORS[index], className: MODEL_TONE_CLASSES[index] };
2224
}
2325

26+
export function modelPageColors(models: ModelRow[]) {
27+
return new Map(sortModels(models, "tokens").map((model, index) => [
28+
model.model,
29+
MODEL_PAGE_COLORS[index % MODEL_PAGE_COLORS.length],
30+
]));
31+
}
32+
2433
export function tokenBreakdown(row: Pick<ModelRow, "inputTokens" | "cachedInputTokens" | "outputTokens">) {
2534
const cached = Math.max(Math.min(row.cachedInputTokens, row.inputTokens), 0);
2635
return {
@@ -46,15 +55,15 @@ export function sortModels(models: ModelRow[], sort: ModelSort) {
4655
});
4756
}
4857

49-
export function buildDonutData(models: ModelRow[], otherLabel: string) {
58+
export function buildDonutData(models: ModelRow[], otherLabel: string, colors = modelPageColors(models)) {
5059
const sorted = sortModels(models, "tokens");
5160
const visible: Array<{ name: string; value: number; color: string }> = sorted.slice(0, 6).map((model) => ({
5261
name: model.model,
5362
value: model.totalTokens,
54-
color: modelTone(model.model).color,
63+
color: colors.get(model.model)!,
5564
}));
5665
const other = sorted.slice(6).reduce((sum, model) => sum + model.totalTokens, 0);
57-
if (other > 0) visible.push({ name: otherLabel, value: other, color: "#94a3b8" });
66+
if (other > 0) visible.push({ name: otherLabel, value: other, color: OTHER_MODEL_COLOR });
5867
return visible;
5968
}
6069

0 commit comments

Comments
 (0)