From baac251b7c4324e4451a719031add2180e84a621 Mon Sep 17 00:00:00 2001 From: shangzy Date: Fri, 8 May 2026 17:15:45 +0800 Subject: [PATCH] docs: add user token breakdown implementation plan --- .../plans/2026-05-08-user-token-breakdown.md | 817 ++++++++++++++++++ 1 file changed, 817 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-user-token-breakdown.md diff --git a/docs/superpowers/plans/2026-05-08-user-token-breakdown.md b/docs/superpowers/plans/2026-05-08-user-token-breakdown.md new file mode 100644 index 0000000..62e5f66 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-user-token-breakdown.md @@ -0,0 +1,817 @@ +# 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`: add `TOKEN_NAME`, token breakdown interfaces, and token/model aggregation inside `getUserDetail()`. +- Modify `lib/queries.test.ts`: add a focused mocked-query test for user token breakdown shape. +- Modify `lib/detail-sort.ts`: make `sortDetailBreakdown()` 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`: + +```ts +const { getTrends, getUserDetail } = await import("./queries"); +``` + +Update `beforeEach()` so every test starts from the existing default trend mock: + +```ts +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`: + +```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: + +```bash +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`: + +```ts +const TOKEN_NAME = `COALESCE(NULLIF(BTRIM(token_name), ''), '')`; +``` + +Replace the `DetailBreakdown` section with these interfaces: + +```ts +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: + +```ts + 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(); + 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`: + +```ts + 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: + +```bash +rtk bun test lib/queries.test.ts +``` + +Expected: PASS for all tests in `lib/queries.test.ts`. + +- [ ] **Step 5: Commit backend data changes** + +Run: + +```bash +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`: + +```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`: + +```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: + +```bash +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: + +```ts +export function sortDetailBreakdown( + 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`: + +```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: + +```bash +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: + +```bash +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: + +```ts + "detail.tokenDist": "令牌分布", + "detail.primaryModels": "主要模型", + "detail.unnamedToken": "未命名令牌", + "detail.expandToken": "展开令牌", + "detail.collapseToken": "收起令牌", +``` + +Add the matching en entries: + +```ts + "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: + +```ts +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`: + +```ts +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: + +```ts +type BreakdownTab = "models" | "tokens"; +``` + +- [ ] **Step 3: Add tab and expansion state** + +Inside `DetailPage()`, after sort state: + +```ts + const [breakdownTab, setBreakdownTab] = useState("models"); + const [expandedTokens, setExpandedTokens] = useState>(() => new Set()); +``` + +Replace the existing breakdown derivation block with: + +```ts + 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()`: + +```ts + 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`: + +```tsx + function renderEmptyBreakdown() { + return ( +
+ {t("common.noData")} +
+ ); + } +``` + +Add this function below it: + +```tsx + function renderTokenDistribution() { + if (sortedTokenItems.length === 0) return renderEmptyBreakdown(); + + return ( + + + + + {breakdownColumns.slice(1).map((col) => { + const key = col.key; + return ( + + ); + })} + + + + + + {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 ( + + toggleToken(item.name)}> + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
{t("th.name")} handleBreakdownSort(key) : undefined} + > + {key ? ( + + {col.label} {renderBreakdownSortIcon(key)} + + ) : col.label} + {t("common.share")}{t("detail.primaryModels")}
+ + + + {tokenLabel} + + {formatNumber(item.calls)}{formatTokens(item.total_tokens)}{formatUSD(item.quota / 500000)}{userShare.toFixed(1)}%{getPrimaryModelNames(item.models) || t("common.noData")}
+
+ + + + + + + + + + + + {item.models.map((model) => { + const modelShare = getSharePercent(model.total_tokens, item.total_tokens); + return ( + + + + + + + + ); + })} + +
{t("detail.model")}{t("th.calls")}{t("th.totalToken")}{t("th.cost")}{t("common.share")}
{model.name}{formatNumber(model.calls)}{formatTokens(model.total_tokens)}{formatUSD(model.quota / 500000)}{modelShare.toFixed(1)}%
+
+
+ ); + } +``` + +- [ ] **Step 5: Replace the breakdown panel rendering** + +Replace the current `{breakdownItems.length > 0 && (...)}` panel with: + +```tsx + {(modelBreakdownItems.length > 0 || showTokenTab) && ( + +
+

+ {activeBreakdownTab === "tokens" ? t("detail.tokenDist") : breakdownLabel} +

+ {showTokenTab && ( +
+ {([ + ["models", t("detail.modelDist")], + ["tokens", t("detail.tokenDist")], + ] as const).map(([value, label]) => ( + + ))} +
+ )} +
+ {activeBreakdownTab === "tokens" ? ( + renderTokenDistribution() + ) : sortedBreakdownItems.length > 0 ? ( + + + + {breakdownColumns.map((col) => { + const key = col.key; + return ( + + ); + })} + + + + {sortedBreakdownItems.map((item) => ( + + + + + + + ))} + +
handleBreakdownSort(key) : undefined} + > + {key ? ( + + {col.label} {renderBreakdownSortIcon(key)} + + ) : col.label} +
{item.name}{formatNumber(item.calls)}{formatTokens(item.total_tokens)}{formatUSD(item.quota / 500000)}
+ ) : renderEmptyBreakdown()} +
+ )} +``` + +- [ ] **Step 6: Run type and lint checks for the UI change** + +Run: + +```bash +rtk npm run lint +rtk npm run build +``` + +Expected: both commands complete without errors. + +- [ ] **Step 7: Commit UI changes** + +Run: + +```bash +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: + +```bash +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: + +```bash +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: + +```bash +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.