diff --git a/src/shared/components/analytics/ModelTable.tsx b/src/shared/components/analytics/ModelTable.tsx new file mode 100644 index 0000000000..16850428d7 --- /dev/null +++ b/src/shared/components/analytics/ModelTable.tsx @@ -0,0 +1,168 @@ +"use client"; + +/** + * Model Breakdown table for Usage analytics (#8769). + * Extracted from charts.tsx so sort logic can be fixed/tested without + * growing the frozen charts god-file. + */ + +import { useCallback, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import Card from "../Card"; +import { getModelColor } from "@/shared/constants/colors"; +import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting"; +import { + nextModelBreakdownSort, + sortModelBreakdownRows, + withModelBreakdownShare, + type ModelBreakdownRow, + type ModelBreakdownSortField, + type ModelBreakdownSortOrder, +} from "./modelBreakdownSort"; + +interface ModelTableProps { + byModel?: ModelBreakdownRow[] | null; + summary?: { totalTokens?: number | null } | null; +} + +type Column = { + field: ModelBreakdownSortField; + labelKey: string; + align: "left" | "right"; +}; + +const COLUMNS: Column[] = [ + { field: "model", labelKey: "chartModel", align: "left" }, + { field: "requests", labelKey: "chartRequests", align: "right" }, + { field: "promptTokens", labelKey: "chartInput", align: "right" }, + { field: "completionTokens", labelKey: "chartOutput", align: "right" }, + { field: "totalTokens", labelKey: "chartTotal", align: "right" }, + { field: "cost", labelKey: "chartCost", align: "right" }, +]; + +function SortIndicator({ active, sortOrder }: { active: boolean; sortOrder: string }) { + if (!active) { + return ( + + unfold_more + + ); + } + return ( + + {sortOrder === "asc" ? "expand_less" : "expand_more"} + + ); +} + +export function ModelTable({ byModel, summary }: ModelTableProps) { + const t = useTranslations("analytics"); + const [sort, setSort] = useState<{ + by: ModelBreakdownSortField; + order: ModelBreakdownSortOrder; + }>({ by: "totalTokens", order: "desc" }); + + const toggleSort = useCallback((field: ModelBreakdownSortField) => { + setSort((prev) => { + const next = nextModelBreakdownSort(prev.by, prev.order, field); + return { by: next.sortBy, order: next.sortOrder }; + }); + }, []); + + const rowsWithShare = useMemo( + () => withModelBreakdownShare(byModel, summary?.totalTokens), + [byModel, summary?.totalTokens] + ); + + const sorted = useMemo( + () => sortModelBreakdownRows(rowsWithShare, sort.by, sort.order), + [rowsWithShare, sort.by, sort.order] + ); + + return ( + +
+

+ {t("chartModelBreakdown")} +

+
+
+ + + + {COLUMNS.map((col) => ( + + ))} + + + + + {sorted.map((m, i) => ( + + + + + + + + + + ))} + +
+ + {t("chartShare")}
+
+ + {m.model} +
+
+ {fmtFull(m.requests)} + + {fmt(m.promptTokens)} + + {fmt(m.completionTokens)} + + {fmt(m.totalTokens)} + + {fmtCost(m.cost)} + +
+
+
+
+ + {m.pct ?? 0}% + +
+
+
+
+ ); +} + +export default ModelTable; diff --git a/src/shared/components/analytics/charts.tsx b/src/shared/components/analytics/charts.tsx index af675e579f..2342fe346b 100644 --- a/src/shared/components/analytics/charts.tsx +++ b/src/shared/components/analytics/charts.tsx @@ -595,143 +595,9 @@ export function WeeklySquares7d({ activityMap }) { ); } -// ── ModelTable ────────────────────────────────────────────────────────────── +// ── ModelTable (extracted — see ModelTable.tsx / #8769) ─────────────────── -export function ModelTable({ byModel, summary }) { - const t = useTranslations("analytics"); - const [sortBy, setSortBy] = useState("totalTokens"); - const [sortOrder, setSortOrder] = useState("desc"); - - const toggleSort = useCallback( - (field) => { - if (sortBy === field) { - setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); - } else { - setSortBy(field); - setSortOrder("desc"); - } - }, - [sortBy] - ); - - const sorted = useMemo(() => { - const arr = [...(byModel || [])]; - arr.sort((a, b) => { - const va = a[sortBy] ?? 0; - const vb = b[sortBy] ?? 0; - if (typeof va === "string") - return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); - return sortOrder === "asc" ? va - vb : vb - va; - }); - return arr; - }, [byModel, sortBy, sortOrder]); - - return ( - -
-

- {t("chartModelBreakdown")} -

-
-
- - - - - - - - - - - - - - {sorted.map((m, i) => ( - - - - - - - - - - ))} - -
toggleSort("model")} - > - {t("chartModel")}{" "} - - toggleSort("requests")} - > - {t("chartRequests")}{" "} - - toggleSort("promptTokens")} - > - {t("chartInput")}{" "} - - toggleSort("completionTokens")} - > - {t("chartOutput")}{" "} - - toggleSort("totalTokens")} - > - {t("chartTotal")}{" "} - - toggleSort("cost")} - > - {t("chartCost")} - {t("chartShare")}
-
- - {m.model} -
-
- {fmtFull(m.requests)} - - {fmt(m.promptTokens)} - - {fmt(m.completionTokens)} - - {fmt(m.totalTokens)} - - {fmtCost(m.cost)} - -
-
-
-
- - {m.pct}% - -
-
-
-
- ); -} +export { ModelTable } from "./ModelTable"; function getServiceTierIcon(serviceTier) { if (serviceTier === "priority") return "bolt"; diff --git a/src/shared/components/analytics/index.tsx b/src/shared/components/analytics/index.tsx index a9eb9d34ee..59aa18a5a2 100644 --- a/src/shared/components/analytics/index.tsx +++ b/src/shared/components/analytics/index.tsx @@ -16,11 +16,11 @@ export { ApiKeyTable, MostActiveDay7d, WeeklySquares7d, - ModelTable, UsageDetail, ProviderTable, ServiceTierBreakdown, } from "./charts"; +export { ModelTable } from "./ModelTable"; export { AccountDonut, ApiKeyDonut, ProviderCostDonut } from "./rechartsDonuts"; export { DailyTrendChart, ModelOverTimeChart } from "./rechartsUsageCharts"; diff --git a/src/shared/components/analytics/modelBreakdownSort.ts b/src/shared/components/analytics/modelBreakdownSort.ts new file mode 100644 index 0000000000..cc2cdfa236 --- /dev/null +++ b/src/shared/components/analytics/modelBreakdownSort.ts @@ -0,0 +1,79 @@ +/** + * Pure sort helper for the Usage → Model Breakdown table (#8769). + * Kept out of charts.tsx so the frozen god-file does not grow and so + * sort behavior is unit-testable without mounting React. + */ + +export type ModelBreakdownSortField = + | "model" + | "requests" + | "promptTokens" + | "completionTokens" + | "totalTokens" + | "cost"; + +export type ModelBreakdownSortOrder = "asc" | "desc"; + +export interface ModelBreakdownRow { + model?: string | null; + requests?: number | null; + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; + cost?: number | null; + pct?: number | string | null; + [key: string]: unknown; +} + +const STRING_FIELDS = new Set(["model"]); + +export function sortModelBreakdownRows( + rows: readonly ModelBreakdownRow[] | null | undefined, + sortBy: ModelBreakdownSortField, + sortOrder: ModelBreakdownSortOrder +): ModelBreakdownRow[] { + const arr = [...(rows || [])]; + arr.sort((a, b) => { + const rawA = a[sortBy]; + const rawB = b[sortBy]; + + if (STRING_FIELDS.has(sortBy) || typeof rawA === "string" || typeof rawB === "string") { + const sa = String(rawA ?? ""); + const sb = String(rawB ?? ""); + const cmp = sa.localeCompare(sb, undefined, { sensitivity: "base", numeric: true }); + return sortOrder === "asc" ? cmp : -cmp; + } + + const va = Number(rawA ?? 0); + const vb = Number(rawB ?? 0); + const safeA = Number.isFinite(va) ? va : 0; + const safeB = Number.isFinite(vb) ? vb : 0; + return sortOrder === "asc" ? safeA - safeB : safeB - safeA; + }); + return arr; +} + +export function nextModelBreakdownSort( + currentBy: ModelBreakdownSortField, + currentOrder: ModelBreakdownSortOrder, + field: ModelBreakdownSortField +): { sortBy: ModelBreakdownSortField; sortOrder: ModelBreakdownSortOrder } { + if (currentBy === field) { + return { sortBy: field, sortOrder: currentOrder === "asc" ? "desc" : "asc" }; + } + return { sortBy: field, sortOrder: "desc" }; +} + +/** Attach share % from summary totals when the API omitted `pct` (#8769 display). */ +export function withModelBreakdownShare( + rows: readonly ModelBreakdownRow[] | null | undefined, + totalTokens: number | null | undefined +): ModelBreakdownRow[] { + const total = Number(totalTokens ?? 0); + return (rows || []).map((row) => { + if (row.pct !== undefined && row.pct !== null && row.pct !== "") return { ...row }; + const tokens = Number(row.totalTokens ?? 0); + const pct = total > 0 ? ((tokens / total) * 100).toFixed(1) : "0"; + return { ...row, pct }; + }); +} diff --git a/tests/unit/model-breakdown-sort-8769.test.ts b/tests/unit/model-breakdown-sort-8769.test.ts new file mode 100644 index 0000000000..e66b9ea842 --- /dev/null +++ b/tests/unit/model-breakdown-sort-8769.test.ts @@ -0,0 +1,89 @@ +/** + * Regression #8769: Usage → Model Breakdown column-header sort must reorder rows. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + nextModelBreakdownSort, + sortModelBreakdownRows, + withModelBreakdownShare, +} from "../../src/shared/components/analytics/modelBreakdownSort.ts"; + +const ROWS = [ + { + model: "alpha", + requests: 10, + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + cost: 0.5, + }, + { + model: "bravo", + requests: 30, + promptTokens: 10, + completionTokens: 90, + totalTokens: 100, + cost: 2.0, + }, + { + model: "charlie", + requests: 5, + promptTokens: 200, + completionTokens: 10, + totalTokens: 210, + cost: 0.1, + }, +]; + +test("#8769: sort by requests desc puts bravo first", () => { + const sorted = sortModelBreakdownRows(ROWS, "requests", "desc"); + assert.deepEqual( + sorted.map((r) => r.model), + ["bravo", "alpha", "charlie"] + ); +}); + +test("#8769: sort by totalTokens asc puts bravo first", () => { + const sorted = sortModelBreakdownRows(ROWS, "totalTokens", "asc"); + assert.deepEqual( + sorted.map((r) => r.model), + ["bravo", "alpha", "charlie"] + ); +}); + +test("#8769: sort by model asc is alphabetical", () => { + const sorted = sortModelBreakdownRows(ROWS, "model", "asc"); + assert.deepEqual( + sorted.map((r) => r.model), + ["alpha", "bravo", "charlie"] + ); +}); + +test("#8769: sort by cost desc puts bravo first", () => { + const sorted = sortModelBreakdownRows(ROWS, "cost", "desc"); + assert.equal(sorted[0].model, "bravo"); + assert.equal(sorted[2].model, "charlie"); +}); + +test("#8769: nextModelBreakdownSort toggles order on same field", () => { + assert.deepEqual(nextModelBreakdownSort("requests", "desc", "requests"), { + sortBy: "requests", + sortOrder: "asc", + }); + assert.deepEqual(nextModelBreakdownSort("requests", "asc", "cost"), { + sortBy: "cost", + sortOrder: "desc", + }); +}); + +test("#8769: withModelBreakdownShare fills missing pct from summary total", () => { + const withPct = withModelBreakdownShare(ROWS, 460); + assert.equal(withPct[0].pct, ((150 / 460) * 100).toFixed(1)); + assert.equal(withPct[1].pct, ((100 / 460) * 100).toFixed(1)); +}); + +test("#8769: sort is stable for empty/null input", () => { + assert.deepEqual(sortModelBreakdownRows(null, "requests", "desc"), []); + assert.deepEqual(sortModelBreakdownRows(undefined, "model", "asc"), []); +});