31 KiB
User Token Breakdown Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a token distribution tab to user detail pages, with expandable per-token model usage.
Architecture: Extend getUserDetail() so the existing detail API returns token rows and nested model rows from logs.token_name. Keep table sorting and display logic in small pure helpers, then wire the client detail page to switch between model distribution and token distribution. Existing model and channel detail pages keep their current behavior.
Tech Stack: Next.js 16 App Router, React 19 client components, TypeScript strict mode, Bun tests, PostgreSQL via pg, lucide-react icons, Tailwind utility classes.
File Structure
- Modify
lib/queries.ts: addTOKEN_NAME, token breakdown interfaces, and token/model aggregation insidegetUserDetail(). - Modify
lib/queries.test.ts: add a focused mocked-query test for user token breakdown shape. - Modify
lib/detail-sort.ts: makesortDetailBreakdown()generic so subtype rows keep nested model fields. - Modify
lib/detail-sort.test.ts: prove the generic sort preserves nested fields and does not mutate rows. - Create
lib/token-breakdown.ts: pure helpers for token row sorting, display names, share percentages, model previews, tab visibility, and stable row keys. - Create
lib/token-breakdown.test.ts: test helper behavior without a browser test harness. - Modify
lib/i18n.tsx: add token distribution labels in zh/en. - Modify
app/detail/[...slug]/page.tsx: add user-only distribution tabs and expandable token rows.
Execution Note
The current repository has no .worktrees/ or worktrees/ directory. Before executing this plan with superpowers:subagent-driven-development or superpowers:executing-plans, use superpowers:using-git-worktrees to choose and prepare an isolated worktree, then run the baseline test command there.
Task 1: Backend User Detail Token Data
Files:
-
Modify:
lib/queries.test.ts -
Modify:
lib/queries.ts -
Step 1: Write the failing user-detail token test
Update the import in lib/queries.test.ts:
const { getTrends, getUserDetail } = await import("./queries");
Update beforeEach() so every test starts from the existing default trend mock:
beforeEach(() => {
queryMock.mockClear();
queryMock.mockImplementation(async () => [
{
date: "2026-04-01 13:00:00",
calls: 1,
prompt_tokens: 10,
completion_tokens: 20,
cache_creation_tokens: 3,
cache_read_tokens: 4,
quota: 100,
},
]);
});
Append this test to lib/queries.test.ts:
test("returns token breakdown with nested model rows for user details", async () => {
queryMock.mockImplementation(async (sql: string) => {
if (sql.includes("token_models AS")) {
return [
{ token_name: "prod-key", model: "claude-sonnet-4", calls: 3, tokens: 100, cache_creation: 5, cache_read: 7, quota: 50 },
{ token_name: "", model: "gpt-4o", calls: 1, tokens: 20, cache_creation: 0, cache_read: 0, quota: 10 },
];
}
if (sql.includes("GROUP BY token_name")) {
return [
{ token_name: "prod-key", calls: 3, tokens: 100, cache_creation: 5, cache_read: 7, quota: 50 },
{ token_name: "", calls: 1, tokens: 20, cache_creation: 0, cache_read: 0, quota: 10 },
];
}
if (sql.includes("GROUP BY model")) {
return [];
}
if (sql.includes("SELECT id, display_name FROM users")) {
return [];
}
if (sql.includes("SELECT id FROM users WHERE username")) {
return [];
}
if (sql.includes("SELECT COUNT(*)::int as calls")) {
return [
{ calls: 4, prompt: 120, completion: 0, cache_creation: 5, cache_read: 7, quota: 60 },
];
}
return [];
});
const detail = await getUserDetail("token-user", 301, 401);
expect(detail.tokens).toEqual([
{
name: "prod-key",
calls: 3,
total_tokens: 112,
quota: 50,
models: [
{ name: "claude-sonnet-4", calls: 3, total_tokens: 112, quota: 50 },
],
},
{
name: "",
calls: 1,
total_tokens: 20,
quota: 10,
models: [
{ name: "gpt-4o", calls: 1, total_tokens: 20, quota: 10 },
],
},
]);
expect(queryMock.mock.calls.some(([sql]) => String(sql).includes("token_name"))).toBe(true);
});
- Step 2: Run the focused test and verify it fails
Run:
rtk bun test lib/queries.test.ts
Expected: FAIL because detail.tokens is undefined before the backend implementation exists.
- Step 3: Add token aggregation to
lib/queries.ts
Add this expression near REAL_MODEL, CACHE_CREATION, and CACHE_READ:
const TOKEN_NAME = `COALESCE(NULLIF(BTRIM(token_name), ''), '')`;
Replace the DetailBreakdown section with these interfaces:
export interface DetailBreakdown {
name: string;
calls: number;
total_tokens: number;
quota: number;
}
export interface TokenDetailBreakdown extends DetailBreakdown {
models: DetailBreakdown[];
}
Inside getUserDetail(), after the existing models query and before display-name lookup, add:
const params3: (string | number | boolean | null)[] = [];
const where3 = timeWhere(params3, startTs, endTs);
params3.push(username);
const tokens = await query(
`SELECT ${TOKEN_NAME} as token_name,
COUNT(*)::int as calls,
COALESCE(SUM(prompt_tokens + completion_tokens),0)::bigint as tokens,
COALESCE(SUM(${CACHE_CREATION}),0)::bigint as cache_creation,
COALESCE(SUM(${CACHE_READ}),0)::bigint as cache_read,
COALESCE(SUM(quota),0)::bigint as quota
FROM logs WHERE ${where3} AND username = $${params3.length}
GROUP BY token_name
ORDER BY COALESCE(SUM(prompt_tokens),0) + COALESCE(SUM(completion_tokens),0) + COALESCE(SUM(${CACHE_CREATION}),0) + COALESCE(SUM(${CACHE_READ}),0) DESC, token_name ASC
LIMIT 50`,
params3
);
const params4: (string | number | boolean | null)[] = [];
const where4 = timeWhere(params4, startTs, endTs);
params4.push(username);
const tokenModels = await query(
`WITH filtered_logs AS (
SELECT ${TOKEN_NAME} as token_name,
${REAL_MODEL} as model,
prompt_tokens,
completion_tokens,
${CACHE_CREATION} as cache_creation,
${CACHE_READ} as cache_read,
quota
FROM logs WHERE ${where4} AND username = $${params4.length}
),
top_tokens AS (
SELECT token_name
FROM filtered_logs
GROUP BY token_name
ORDER BY COALESCE(SUM(prompt_tokens),0) + COALESCE(SUM(completion_tokens),0) + COALESCE(SUM(cache_creation),0) + COALESCE(SUM(cache_read),0) DESC, token_name ASC
LIMIT 50
),
token_models AS (
SELECT filtered_logs.token_name,
filtered_logs.model,
COUNT(*)::int as calls,
COALESCE(SUM(prompt_tokens + completion_tokens),0)::bigint as tokens,
COALESCE(SUM(cache_creation),0)::bigint as cache_creation,
COALESCE(SUM(cache_read),0)::bigint as cache_read,
COALESCE(SUM(quota),0)::bigint as quota
FROM filtered_logs
INNER JOIN top_tokens ON filtered_logs.token_name = top_tokens.token_name
GROUP BY filtered_logs.token_name, filtered_logs.model
),
ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY token_name ORDER BY tokens + cache_creation + cache_read DESC, model ASC) as rn
FROM token_models
)
SELECT token_name, model, calls, tokens, cache_creation, cache_read, quota
FROM ranked
WHERE rn <= 10
ORDER BY token_name ASC, tokens + cache_creation + cache_read DESC, model ASC`,
params4
);
const modelsByToken = new Map<string, DetailBreakdown[]>();
for (const row of tokenModels) {
const tokenName = String(row.token_name ?? "");
const rows = modelsByToken.get(tokenName) ?? [];
rows.push({
name: String(row.model || "(unknown)"),
calls: Number(row.calls),
total_tokens: Number(row.tokens) + Number(row.cache_creation) + Number(row.cache_read),
quota: Number(row.quota),
});
modelsByToken.set(tokenName, rows);
}
Add tokens to the getUserDetail() return object after models:
tokens: tokens.map((token): TokenDetailBreakdown => {
const name = String(token.token_name ?? "");
return {
name,
calls: Number(token.calls),
total_tokens: Number(token.tokens) + Number(token.cache_creation) + Number(token.cache_read),
quota: Number(token.quota),
models: modelsByToken.get(name) ?? [],
};
}),
- Step 4: Run the focused backend test and verify it passes
Run:
rtk bun test lib/queries.test.ts
Expected: PASS for all tests in lib/queries.test.ts.
- Step 5: Commit backend data changes
Run:
rtk git add lib/queries.ts lib/queries.test.ts
rtk git commit -m "feat: add user token breakdown data"
Expected: commit succeeds.
Task 2: Token Breakdown Pure Helpers
Files:
-
Modify:
lib/detail-sort.ts -
Modify:
lib/detail-sort.test.ts -
Create:
lib/token-breakdown.ts -
Create:
lib/token-breakdown.test.ts -
Step 1: Write failing helper tests
Append this test to lib/detail-sort.test.ts:
test("preserves subtype fields while sorting breakdown rows", () => {
const rows = [
{ name: "beta", calls: 2, total_tokens: 100, quota: 500, models: [{ name: "m2", calls: 1, total_tokens: 20, quota: 10 }] },
{ name: "alpha", calls: 5, total_tokens: 200, quota: 300, models: [{ name: "m1", calls: 1, total_tokens: 30, quota: 20 }] },
];
const sorted = sortDetailBreakdown(rows, "total_tokens", false);
expect(sorted[0].models[0].name).toBe("m1");
expect(rows[0].models[0].name).toBe("m2");
});
Create lib/token-breakdown.test.ts:
import { describe, expect, test } from "bun:test";
import {
getPrimaryModelNames,
getSharePercent,
getTokenDisplayName,
getTokenRowKey,
shouldShowTokenTab,
sortTokenBreakdown,
} from "./token-breakdown";
describe("token breakdown helpers", () => {
const rows = [
{
name: "beta",
calls: 2,
total_tokens: 100,
quota: 500,
models: [{ name: "gpt-4o", calls: 1, total_tokens: 70, quota: 300 }],
},
{
name: "alpha",
calls: 5,
total_tokens: 300,
quota: 300,
models: [{ name: "claude-sonnet-4", calls: 1, total_tokens: 200, quota: 200 }],
},
];
test("sorts token rows without mutating nested model data", () => {
const sorted = sortTokenBreakdown(rows, "total_tokens", false);
expect(sorted.map((row) => row.name)).toEqual(["alpha", "beta"]);
expect(sorted[0].models[0].name).toBe("claude-sonnet-4");
expect(rows.map((row) => row.name)).toEqual(["beta", "alpha"]);
});
test("uses a localized label for unnamed tokens", () => {
expect(getTokenDisplayName("", "未命名令牌")).toBe("未命名令牌");
expect(getTokenDisplayName("prod-key", "未命名令牌")).toBe("prod-key");
});
test("keeps unnamed token row keys stable", () => {
expect(getTokenRowKey("")).toBe("__unnamed_token__");
expect(getTokenRowKey("prod-key")).toBe("prod-key");
});
test("shows the token tab only for user details", () => {
expect(shouldShowTokenTab("user")).toBe(true);
expect(shouldShowTokenTab("model")).toBe(false);
expect(shouldShowTokenTab("channel")).toBe(false);
});
test("calculates share percentages and handles empty totals", () => {
expect(getSharePercent(25, 100)).toBe(25);
expect(getSharePercent(25, 0)).toBe(0);
});
test("formats a compact primary model preview", () => {
expect(getPrimaryModelNames([
{ name: "a", calls: 1, total_tokens: 1, quota: 1 },
{ name: "b", calls: 1, total_tokens: 1, quota: 1 },
{ name: "c", calls: 1, total_tokens: 1, quota: 1 },
{ name: "d", calls: 1, total_tokens: 1, quota: 1 },
])).toBe("a, b, c");
});
});
- Step 2: Run helper tests and verify they fail
Run:
rtk bun test lib/detail-sort.test.ts lib/token-breakdown.test.ts
Expected: FAIL because lib/token-breakdown.ts does not exist.
- Step 3: Implement generic sorting and token helpers
Replace sortDetailBreakdown() in lib/detail-sort.ts with:
export function sortDetailBreakdown<T extends DetailBreakdownItem>(
items: T[],
sortKey: DetailBreakdownSortKey,
sortAsc: boolean
): T[] {
return [...items].sort((a, b) => {
const diff = a[sortKey] - b[sortKey];
if (diff !== 0) return sortAsc ? diff : -diff;
return a.name.localeCompare(b.name);
});
}
Create lib/token-breakdown.ts:
import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "./detail-sort";
export interface TokenBreakdownItem extends DetailBreakdownItem {
models: DetailBreakdownItem[];
}
export type TokenBreakdownSortKey = DetailBreakdownSortKey;
export function sortTokenBreakdown(
items: TokenBreakdownItem[],
sortKey: TokenBreakdownSortKey,
sortAsc: boolean
): TokenBreakdownItem[] {
return sortDetailBreakdown(items, sortKey, sortAsc);
}
export function getTokenDisplayName(name: string, unnamedLabel: string): string {
return name === "" ? unnamedLabel : name;
}
export function getTokenRowKey(name: string): string {
return name === "" ? "__unnamed_token__" : name;
}
export function shouldShowTokenTab(detailType: string): boolean {
return detailType === "user";
}
export function getSharePercent(part: number, total: number): number {
return total > 0 ? (part / total) * 100 : 0;
}
export function getPrimaryModelNames(models: DetailBreakdownItem[], limit = 3): string {
return models.slice(0, limit).map((model) => model.name || "(unknown)").join(", ");
}
- Step 4: Run helper tests and verify they pass
Run:
rtk bun test lib/detail-sort.test.ts lib/token-breakdown.test.ts
Expected: PASS for both test files.
- Step 5: Commit helper changes
Run:
rtk git add lib/detail-sort.ts lib/detail-sort.test.ts lib/token-breakdown.ts lib/token-breakdown.test.ts
rtk git commit -m "feat: add token breakdown helpers"
Expected: commit succeeds.
Task 3: User Detail Token Distribution UI
Files:
-
Modify:
lib/i18n.tsx -
Modify:
app/detail/[...slug]/page.tsx -
Step 1: Add translation keys
In lib/i18n.tsx, add these zh entries near the existing detail keys:
"detail.tokenDist": "令牌分布",
"detail.primaryModels": "主要模型",
"detail.unnamedToken": "未命名令牌",
"detail.expandToken": "展开令牌",
"detail.collapseToken": "收起令牌",
Add the matching en entries:
"detail.tokenDist": "Token Distribution",
"detail.primaryModels": "Primary Models",
"detail.unnamedToken": "Unnamed Token",
"detail.expandToken": "Expand token",
"detail.collapseToken": "Collapse token",
- Step 2: Update detail page imports and types
In app/detail/[...slug]/page.tsx, update imports:
import { Fragment, useEffect, useState, useCallback, startTransition } from "react";
import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen, ArrowUpDown, ArrowDown, ArrowUp, ChevronRight, KeyRound } from "lucide-react";
import { getPrimaryModelNames, getSharePercent, getTokenDisplayName, getTokenRowKey, shouldShowTokenTab, sortTokenBreakdown, type TokenBreakdownItem } from "@/lib/token-breakdown";
Update DetailData:
interface DetailData {
calls: number; prompt_tokens: number; completion_tokens: number;
cache_creation_tokens: number; cache_read_tokens: number;
total_tokens: number; quota: number; display_name?: string;
models?: DetailBreakdownItem[];
users?: DetailBreakdownItem[];
tokens?: TokenBreakdownItem[];
channel_name?: string;
}
Add this local type:
type BreakdownTab = "models" | "tokens";
- Step 3: Add tab and expansion state
Inside DetailPage(), after sort state:
const [breakdownTab, setBreakdownTab] = useState<BreakdownTab>("models");
const [expandedTokens, setExpandedTokens] = useState<Set<string>>(() => new Set());
Replace the existing breakdown derivation block with:
const title = type === "channel" ? (data?.channel_name || decodedId) : (data?.display_name || decodedId);
const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type;
const modelBreakdownItems = data?.models || data?.users || [];
const tokenBreakdownItems = data?.tokens || [];
const showTokenTab = shouldShowTokenTab(type);
const activeBreakdownTab = showTokenTab ? breakdownTab : "models";
const sortedBreakdownItems = sortDetailBreakdown(modelBreakdownItems, breakdownSortKey, breakdownSortAsc);
const sortedTokenItems = sortTokenBreakdown(tokenBreakdownItems, breakdownSortKey, breakdownSortAsc);
const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist");
Add this toggle function below handleBreakdownSort():
function toggleToken(tokenName: string) {
setExpandedTokens((current) => {
const next = new Set(current);
if (next.has(tokenName)) next.delete(tokenName);
else next.add(tokenName);
return next;
});
}
- Step 4: Add token table helpers inside the component
Add this function before return:
function renderEmptyBreakdown() {
return (
<div className="px-5 py-10 text-center text-sm" style={{ color: "var(--text-muted)" }}>
{t("common.noData")}
</div>
);
}
Add this function below it:
function renderTokenDistribution() {
if (sortedTokenItems.length === 0) return renderEmptyBreakdown();
return (
<table className="w-full text-sm mt-3">
<thead>
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.name")}</th>
{breakdownColumns.slice(1).map((col) => {
const key = col.key;
return (
<th key={col.label}
className={`px-5 py-3 text-xs font-medium uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"} ${key ? "cursor-pointer select-none transition-colors hover:opacity-80" : ""}`}
style={{ color: key && breakdownSortKey === key ? "var(--text-accent)" : "var(--text-muted)" }}
onClick={key ? () => handleBreakdownSort(key) : undefined}
>
{key ? (
<span className="inline-flex items-center gap-1 justify-end">
{col.label} {renderBreakdownSortIcon(key)}
</span>
) : col.label}
</th>
);
})}
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("detail.primaryModels")}</th>
</tr>
</thead>
<tbody>
{sortedTokenItems.map((item) => {
const isExpanded = expandedTokens.has(item.name);
const tokenLabel = getTokenDisplayName(item.name, t("detail.unnamedToken"));
const userShare = getSharePercent(item.total_tokens, data?.total_tokens ?? 0);
return (
<Fragment key={getTokenRowKey(item.name)}>
<tr className="row-glow cursor-pointer transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }} onClick={() => toggleToken(item.name)}>
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.85 }}>
<span className="inline-flex items-center gap-2">
<button
type="button"
aria-label={isExpanded ? t("detail.collapseToken") : t("detail.expandToken")}
className="inline-flex h-6 w-6 items-center justify-center rounded-md transition-colors"
style={{ color: "var(--text-muted)", background: "var(--btn-active-bg)", border: "1px solid var(--surface-border)" }}
onClick={(event) => { event.stopPropagation(); toggleToken(item.name); }}
>
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isExpanded ? "rotate-90" : ""}`} />
</button>
<KeyRound className="h-3.5 w-3.5" style={{ color: "var(--accent)", opacity: 0.55 }} />
<span>{tokenLabel}</span>
</span>
</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{userShare.toFixed(1)}%</td>
<td className="px-5 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{getPrimaryModelNames(item.models) || t("common.noData")}</td>
</tr>
{isExpanded && (
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
<td colSpan={6} className="px-5 pb-4 pt-0">
<div className="ml-8 overflow-hidden rounded-lg" style={{ border: "1px solid var(--surface-border)", background: "var(--row-hover)" }}>
<table className="w-full text-xs">
<thead>
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
<th className="px-4 py-2 text-left font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("detail.model")}</th>
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.calls")}</th>
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.totalToken")}</th>
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("th.cost")}</th>
<th className="px-4 py-2 text-right font-medium uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>{t("common.share")}</th>
</tr>
</thead>
<tbody>
{item.models.map((model) => {
const modelShare = getSharePercent(model.total_tokens, item.total_tokens);
return (
<tr key={`${item.name}:${model.name}`} style={{ borderBottom: "1px solid var(--surface-border)" }}>
<td className="px-4 py-2 font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-accent)", opacity: 0.75 }}>{model.name}</td>
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-secondary)" }}>{formatNumber(model.calls)}</td>
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(model.total_tokens)}</td>
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-secondary)" }}>{formatUSD(model.quota / 500000)}</td>
<td className="px-4 py-2 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{modelShare.toFixed(1)}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
);
}
- Step 5: Replace the breakdown panel rendering
Replace the current {breakdownItems.length > 0 && (...)} panel with:
{(modelBreakdownItems.length > 0 || showTokenTab) && (
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="glass overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 pt-5">
<h2 className="text-xs font-medium uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>
{activeBreakdownTab === "tokens" ? t("detail.tokenDist") : breakdownLabel}
</h2>
{showTokenTab && (
<div className="flex gap-1 rounded-lg p-1" style={{ background: "var(--row-hover)", border: "1px solid var(--surface-border)" }}>
{([
["models", t("detail.modelDist")],
["tokens", t("detail.tokenDist")],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setBreakdownTab(value)}
className="rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
style={{
background: activeBreakdownTab === value ? "var(--btn-active-bg)" : "transparent",
color: activeBreakdownTab === value ? "var(--text-accent)" : "var(--text-muted)",
border: activeBreakdownTab === value ? "1px solid var(--surface-border)" : "1px solid transparent",
}}
>
{label}
</button>
))}
</div>
)}
</div>
{activeBreakdownTab === "tokens" ? (
renderTokenDistribution()
) : sortedBreakdownItems.length > 0 ? (
<table className="w-full text-sm mt-3">
<thead>
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
{breakdownColumns.map((col) => {
const key = col.key;
return (
<th key={col.label}
className={`px-5 py-3 text-xs font-medium uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"} ${key ? "cursor-pointer select-none transition-colors hover:opacity-80" : ""}`}
style={{ color: key && breakdownSortKey === key ? "var(--text-accent)" : "var(--text-muted)" }}
onClick={key ? () => handleBreakdownSort(key) : undefined}
>
{key ? (
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "justify-end" : ""}`}>
{col.label} {renderBreakdownSortIcon(key)}
</span>
) : col.label}
</th>
);
})}
</tr>
</thead>
<tbody>
{sortedBreakdownItems.map((item) => (
<tr key={item.name} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
<td className="px-5 py-3" style={{ color: "var(--text-accent)", opacity: 0.8 }}>{item.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatNumber(item.calls)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-primary)" }}>{formatTokens(item.total_tokens)}</td>
<td className="px-5 py-3 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-secondary)" }}>{formatUSD(item.quota / 500000)}</td>
</tr>
))}
</tbody>
</table>
) : renderEmptyBreakdown()}
</motion.div>
)}
- Step 6: Run type and lint checks for the UI change
Run:
rtk npm run lint
rtk npm run build
Expected: both commands complete without errors.
- Step 7: Commit UI changes
Run:
rtk git add 'app/detail/[...slug]/page.tsx' lib/i18n.tsx
rtk git commit -m "feat: add user token distribution tab"
Expected: commit succeeds.
Task 4: End-To-End Verification
Files:
-
No source edits unless verification exposes a defect.
-
Step 1: Run all automated checks
Run:
rtk bun test
rtk npm run lint
rtk npm run build
Expected: all tests, lint, and production build pass.
- Step 2: Start the development server
Run:
rtk npm run dev
Expected: Next.js starts and prints a local URL, usually http://localhost:3000.
- Step 3: Manually verify the user detail flow
Open the local URL and verify:
-
Rankings user rows still link to
/detail/user/{username}. -
User detail pages default to 模型分布 / Model Distribution.
-
The new 令牌分布 / Token Distribution tab appears only for user detail pages.
-
Token rows show calls, total tokens, cost, user share, and primary model preview.
-
Clicking a token expands nested model rows with calls, total tokens, cost, and token share.
-
Model and channel detail pages still show their existing single distribution table.
-
Step 4: Commit verification fixes if needed
If verification required a source fix, run:
rtk git status --short
rtk git add 'app/detail/[...slug]/page.tsx' lib/queries.ts lib/queries.test.ts lib/detail-sort.ts lib/detail-sort.test.ts lib/token-breakdown.ts lib/token-breakdown.test.ts lib/i18n.tsx
rtk git commit -m "fix: polish token breakdown verification"
Expected: no commit is needed when verification passes without source edits.