Files
new-api-analytics/docs/superpowers/plans/2026-05-08-user-token-breakdown.md

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: 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:

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.