diff --git a/bun.lock b/bun.lock
index 9525f19..cb0bdce 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,7 +9,7 @@
"@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3",
- "@moduletrace/twui": "^0.1.6",
+ "@moduletrace/twui": "^0.1.7",
"@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0",
@@ -255,7 +255,7 @@
"@moduletrace/bunext": ["@moduletrace/bunext@1.3.3", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
- "@moduletrace/twui": ["@moduletrace/twui@0.1.6", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.6/twui-0.1.6.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-BFNIV1itkIYXmhq1rBJA8E8MjwN9AP7MESbOnzM0yUb6xbsOFIBbmlKfUu6kTr7QCl1TWNxWTGFPVmQ2S54cDA=="],
+ "@moduletrace/twui": ["@moduletrace/twui@0.1.7", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.7/twui-0.1.7.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-/Y9QyhtQsoEAJO+ahlGYQaYMx3gUWS+DlSkaBVjAIY5V9OQ6i7qhhqMNcmnfOi9m3eyAze1uW00jox+RDUqLpA=="],
"@next/mdx": ["@next/mdx@16.3.0", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="],
diff --git a/package.json b/package.json
index 7ee0c8c..54ac4b4 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
"@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3",
- "@moduletrace/twui": "^0.1.6",
+ "@moduletrace/twui": "^0.1.7",
"@tiptap/extension-bubble-menu": "3.32.0",
"@tiptap/extension-mention": "3.32.0",
"@tiptap/extension-placeholder": "3.32.0",
diff --git a/src/pages/admin/usage/(data)/usage-table-columns.ts b/src/pages/admin/usage/(data)/usage-table-columns.ts
new file mode 100644
index 0000000..0c44141
--- /dev/null
+++ b/src/pages/admin/usage/(data)/usage-table-columns.ts
@@ -0,0 +1,12 @@
+/**
+ * The column tracks of the models table, shared by its heading and every
+ * row, so a figure always sits under its own heading whatever its width.
+ * The table never gets narrower than its columns: it scrolls instead.
+ */
+export const UsageModelColumns =
+ "grid grid-cols-[minmax(160px,1fr)_48px_56px_56px_64px_64px_64px] min-w-[620px]";
+
+/**
+ * The column tracks of the breakdown by kind of call, the same way.
+ */
+export const UsageHarnessColumns = "grid grid-cols-[minmax(0,1fr)_40px_72px_64px]";
diff --git a/src/pages/admin/usage/(partials)/usage-day-bar.tsx b/src/pages/admin/usage/(partials)/usage-day-bar.tsx
index 38540c7..2df1b05 100644
--- a/src/pages/admin/usage/(partials)/usage-day-bar.tsx
+++ b/src/pages/admin/usage/(partials)/usage-day-bar.tsx
@@ -1,39 +1,44 @@
+import Popover from "@moduletrace/twui/elements/Popover";
import Stack from "@moduletrace/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
-import dayjs from "dayjs";
import type { UsageOverview } from "@/src/types";
-import formatTokens from "@/src/utils/format-tokens";
import { usageBarHeight } from "../(data)/usage-bar-heights";
+import UsageDaySummary from "./usage-day-summary";
type Props = {
day: UsageOverview["days"][number];
max: number;
+ /**
+ * Every day's tokens in the period, for the day's share.
+ */
+ total_tokens: number;
};
/**
- * One day's bar in the chart: its tokens above nothing, the bar itself,
- * and the title spells the day out with what it spent. Hovering it shows
- * that title.
+ * One day's bar in the chart. Hovering it (or tapping it) opens a small
+ * card above it summing the day up.
*/
-export default function UsageDayBar({ day, max }: Props) {
- const title = `${dayjs(day.at).format("ddd, MMM D")}: ${formatTokens({ tokens: day.total_tokens })} tokens, ${daysCalls({ calls: day.calls })}`;
-
+export default function UsageDayBar({ day, max, total_tokens }: Props) {
return (
-
+ }
+ className="w-56 p-3 gap-2! bg-surface dark:bg-surface border-hairline dark:border-hairline rounded-default shadow-popover"
>
-
-
+
+
);
}
-
-function daysCalls({ calls }: { calls: number }) {
- return `${calls} ${calls == 1 ? "call" : "calls"}`;
-}
diff --git a/src/pages/admin/usage/(partials)/usage-day-chart.tsx b/src/pages/admin/usage/(partials)/usage-day-chart.tsx
index 3457a76..0187e5f 100644
--- a/src/pages/admin/usage/(partials)/usage-day-chart.tsx
+++ b/src/pages/admin/usage/(partials)/usage-day-chart.tsx
@@ -13,10 +13,12 @@ type Props = {
/**
* The period day by day, one bar a day, so a spike or a quiet stretch is
- * read at a glance. The busiest day's total labels the top.
+ * read at a glance. The busiest day's total labels the top, and each bar
+ * opens a small summary of its day.
*/
export default function UsageDayChart({ days }: Props) {
const max = days.reduce((highest, day) => Math.max(highest, day.total_tokens), 0);
+ const total_tokens = days.reduce((total, day) => total + day.total_tokens, 0);
const first = days[0];
const last = days[days.length - 1];
@@ -30,9 +32,9 @@ export default function UsageDayChart({ days }: Props) {
}
>
-
+
{days.map((day) => (
-
+
))}
{first && last ? (
diff --git a/src/pages/admin/usage/(partials)/usage-day-summary.tsx b/src/pages/admin/usage/(partials)/usage-day-summary.tsx
new file mode 100644
index 0000000..06db4c0
--- /dev/null
+++ b/src/pages/admin/usage/(partials)/usage-day-summary.tsx
@@ -0,0 +1,49 @@
+import Span from "@moduletrace/twui/layout/Span";
+import Stack from "@moduletrace/twui/layout/Stack";
+import dayjs from "dayjs";
+import type { UsageOverview } from "@/src/types";
+import formatTokens from "@/src/utils/format-tokens";
+import UsageSummaryLine from "./usage-summary-line";
+
+type Props = {
+ day: UsageOverview["days"][number];
+ /**
+ * Every day's tokens in the period, for the day's share.
+ */
+ total_tokens: number;
+};
+
+/**
+ * What one day spent, in the card its bar opens: the day spelled out, then
+ * its tokens, how they split, its calls, its cost and its share of the
+ * period.
+ */
+export default function UsageDaySummary({ day, total_tokens }: Props) {
+ const share = total_tokens ? Math.round((day.total_tokens / total_tokens) * 100) : 0;
+
+ const lines = [
+ { label: "Tokens", value: formatTokens({ tokens: day.total_tokens }) },
+ { label: "In", value: formatTokens({ tokens: day.prompt_tokens }) },
+ { label: "Out", value: formatTokens({ tokens: day.completion_tokens }) },
+ { label: "Model calls", value: formatTokens({ tokens: day.calls }) },
+ { label: "Cost", value: day.cost ? `$${day.cost.toFixed(2)}` : "-" },
+ { label: "Share of period", value: `${share}%` },
+ ];
+
+ return (
+
+
+ {dayjs(day.at).format("dddd, MMM D")}
+
+ {day.calls ? (
+
+ {lines.map((line) => (
+
+ ))}
+
+ ) : (
+ No model calls.
+ )}
+
+ );
+}
diff --git a/src/pages/admin/usage/(partials)/usage-harness-breakdown.tsx b/src/pages/admin/usage/(partials)/usage-harness-breakdown.tsx
index 3c8b3e7..7d35eaa 100644
--- a/src/pages/admin/usage/(partials)/usage-harness-breakdown.tsx
+++ b/src/pages/admin/usage/(partials)/usage-harness-breakdown.tsx
@@ -3,7 +3,9 @@ import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { UsageOverview } from "@/src/types";
+import { twMerge } from "tailwind-merge";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
+import { UsageHarnessColumns } from "../(data)/usage-table-columns";
import sumTokens from "../(functions)/sum-tokens";
import UsageHarnessRow from "./usage-harness-row";
@@ -32,17 +34,22 @@ export default function UsageHarnessBreakdown({ rows }: Props) {
return (
-
-
+
+
Kind
-
+
Share
-
+
Tokens
-
+
Cost
diff --git a/src/pages/admin/usage/(partials)/usage-harness-row.tsx b/src/pages/admin/usage/(partials)/usage-harness-row.tsx
index 77bcf99..5bbdfcf 100644
--- a/src/pages/admin/usage/(partials)/usage-harness-row.tsx
+++ b/src/pages/admin/usage/(partials)/usage-harness-row.tsx
@@ -1,8 +1,10 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
+import { twMerge } from "tailwind-merge";
import type { UsageOverview } from "@/src/types";
import formatTokens from "@/src/utils/format-tokens";
import { UsageHarnessTitles } from "../(data)/usage-harness-titles";
+import { UsageHarnessColumns } from "../(data)/usage-table-columns";
type Props = {
row: UsageOverview["by_harness"][number];
@@ -20,17 +22,22 @@ export default function UsageHarnessRow({ row, total_tokens }: Props) {
const share = total_tokens ? Math.round((row.total_tokens / total_tokens) * 100) : 0;
return (
-
-
+
+
{UsageHarnessTitles[row.harness] || row.harness}
-
+
{share}%
-
+
{formatTokens({ tokens: row.total_tokens })}
-
+
{row.cost ? `$${row.cost.toFixed(2)}` : "-"}
diff --git a/src/pages/admin/usage/(partials)/usage-model-head-cell.tsx b/src/pages/admin/usage/(partials)/usage-model-head-cell.tsx
index 5a34d38..61c2793 100644
--- a/src/pages/admin/usage/(partials)/usage-model-head-cell.tsx
+++ b/src/pages/admin/usage/(partials)/usage-model-head-cell.tsx
@@ -13,7 +13,7 @@ export default function UsageModelHeadCell({ title, className }: Props) {
return (
diff --git a/src/pages/admin/usage/(partials)/usage-model-row.tsx b/src/pages/admin/usage/(partials)/usage-model-row.tsx
index 43ef94d..5c2bfcb 100644
--- a/src/pages/admin/usage/(partials)/usage-model-row.tsx
+++ b/src/pages/admin/usage/(partials)/usage-model-row.tsx
@@ -1,9 +1,11 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
+import { twMerge } from "tailwind-merge";
import type { UsageOverview } from "@/src/types";
import formatTokens from "@/src/utils/format-tokens";
import { UsageHarnessTitles } from "../(data)/usage-harness-titles";
+import { UsageModelColumns } from "../(data)/usage-table-columns";
type Props = {
row: UsageOverview["models"][number];
@@ -20,8 +22,13 @@ export default function UsageModelRow({ row }: Props) {
: "-";
return (
-
-
+
+
{row.model_name || row.model_slug}
@@ -29,22 +36,22 @@ export default function UsageModelRow({ row }: Props) {
{row.provider_name} - {UsageHarnessTitles[row.harness] || row.harness}
-
+
{formatTokens({ tokens: row.calls })}
-
+
{formatTokens({ tokens: row.prompt_tokens })}
-
+
{formatTokens({ tokens: row.completion_tokens })}
-
+
{formatTokens({ tokens: row.total_tokens })}
-
+
{per_thousand}
-
+
{row.cost ? `$${row.cost.toFixed(2)}` : "-"}
diff --git a/src/pages/admin/usage/(partials)/usage-models-table.tsx b/src/pages/admin/usage/(partials)/usage-models-table.tsx
index 20b41e3..2035b68 100644
--- a/src/pages/admin/usage/(partials)/usage-models-table.tsx
+++ b/src/pages/admin/usage/(partials)/usage-models-table.tsx
@@ -3,7 +3,9 @@ import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { UsageOverview } from "@/src/types";
+import { twMerge } from "tailwind-merge";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
+import { UsageModelColumns } from "../(data)/usage-table-columns";
import UsageModelHeadCell from "./usage-model-head-cell";
import UsageModelRow from "./usage-model-row";
@@ -34,14 +36,19 @@ export default function UsageModelsTable({ rows, days }: Props) {
description={`The models this install ran in the last ${days} days, busiest first. The price per thousand tokens is read from what the provider reported.`}
>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{rows.map((row) => (
void;
+};
+
+/**
+ * One period of the range picker: ink-filled while it is the one showing,
+ * quiet otherwise.
+ */
+export default function UsageRangeButton({ range, selected, loading, onChange }: Props) {
+ return (
+
+ );
+}
diff --git a/src/pages/admin/usage/(partials)/usage-range-picker.tsx b/src/pages/admin/usage/(partials)/usage-range-picker.tsx
index 82742b9..6cd4a1f 100644
--- a/src/pages/admin/usage/(partials)/usage-range-picker.tsx
+++ b/src/pages/admin/usage/(partials)/usage-range-picker.tsx
@@ -1,6 +1,6 @@
-import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { UsageRanges } from "../(data)/usage-ranges";
+import UsageRangeButton from "./usage-range-button";
type Props = {
days: number;
@@ -9,26 +9,20 @@ type Props = {
};
/**
- * The periods the page totals, one button each. The one showing is the
- * one that reads as pressed.
+ * The periods the page totals, one segment each in a hairline track. The
+ * one showing is filled in ink, so it reads as pressed.
*/
export default function UsageRangePicker({ days, loading, onChange }: Props) {
return (
-
+
{UsageRanges.map((range) => (
-
+ range={range}
+ selected={range.days == days}
+ loading={loading}
+ onChange={onChange}
+ />
))}
);
diff --git a/src/pages/admin/usage/(partials)/usage-summary-line.tsx b/src/pages/admin/usage/(partials)/usage-summary-line.tsx
new file mode 100644
index 0000000..fe52d68
--- /dev/null
+++ b/src/pages/admin/usage/(partials)/usage-summary-line.tsx
@@ -0,0 +1,20 @@
+import Row from "@moduletrace/twui/layout/Row";
+import Span from "@moduletrace/twui/layout/Span";
+
+type Props = {
+ label: string;
+ value: string;
+};
+
+/**
+ * One figure of a day's summary card: what it is on the left, the figure
+ * on the right.
+ */
+export default function UsageSummaryLine({ label, value }: Props) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}