.agents/skills/ux-audit/references/example/stats.md
A real run of this skill against the per-agent usage & cost dashboard
(/agent/:aid/stats → src/features/AgentUsage), 2026-07-02 (LOBE-11218, under the
Chat / 会话 UX-Audit parent LOBE-11145). Use it as a template for the output shape, not as
current-state truth (the code moves; re-verify before citing).
Surface = a single scrolling column of three Block cards: ① summary stat cards
(cost / cache savings / token) with a dimension (day/week) + range (7/30/90d) toolbar →
② "when this agent spent" stacked bar chart with a spend/token toggle → ③ per-model
breakdown table. Chrome = NavHeader + AgentBreadcrumb.
Layers run: L1 (static / code) ✅ — everything below. L2 (visual) / L3 (dynamic + CLS) ⏳ not yet run — see §5. Verdicts about the render (chart sparseness, does $0 read as real data) are L1 inferences here, pending L2 confirmation.
Surface class & its norms (benchmark first). This is a usage / cost dashboard — the reference class is Anthropic Console usage, the OpenAI usage dashboard, Vercel/Stripe analytics, AWS Cost Explorer. Class norms an L1 code-read is otherwise blind to (each checked below): date-range + granularity control ✅; period-over-period comparison (▲/▼ % vs previous range) ✗; export / download (CSV) ✗; drill-down from an aggregate to the records that produced it ✗; a purpose-built empty / no-usage state ✗; failure ≠ $0 ✗.
| Pattern (family) | Where | Rating | Note |
|---|---|---|---|
| Visual Framework (layout) | NavHeader + WideScreenContainer centered column (index.tsx:52) | ✅ | consistent app chrome |
| Titled Sections / Card Stack (layout) | three Block variant="outlined" cards (index.tsx:63,101,104) | ✅ | clean top-to-bottom read |
| Breadcrumbs (nav) | AgentBreadcrumb with usageStats.title (index.tsx:56) | ⚠️ | renders null when no active agent (index.tsx:55) |
| Sortable Table (data) | per-model breakdown antd Table (ModelBreakdown.tsx:65) | ⚠️ | pagination={false}, columns not actually sortable, no row → detail |
| Dynamic Queries (data) | day/week + 7/30/90d Segmented (index.tsx:69,83); spend/token toggle (UsageTrendChart.tsx:51) | ⚠️ | selection is useState — not persisted, not in URL (gap ④) |
| Datatips / Data Brushing (data) | BarChart stacked series with tooltips (UsageTrendChart.tsx:63) | ✅ | interactive read of input/output/cache |
| Number formatting (data) | formatUsageValue / formatNumber / formatTokenNumber everywhere | ✅ | shared unit-rolling helpers, Read §1.5 (highlight) |
| Skeleton loading (feedback) | chart Skeleton.Block height={320} (UsageTrendChart.tsx:61) | ✅ | height matches the loaded chart exactly, 0 CLS (highlight) |
| Loading Indicator (feedback) | StatisticCard antd Spin (StatisticCard/index.tsx:166); table antd spin (ModelBreakdown.tsx:70) | ⚠️ | antd Spin, not project loader / skeleton (gap ⑥) |
| Failure + Retry (feedback) | — | — | absent — no error branch anywhere (gap ①) |
| Empty-state as onboarding (growth) | — | — | absent — no "no usage yet" page (gap ②) |
| Overview + Detail / drill-down (data) | — | — | absent — no aggregate → source-records path (gap ⑤) |
| Comparison / trend delta (data) | — | — | absent — TitleWithPercentage exists but unused (gap ⑤) |
Read: layout, number formatting, and the chart skeleton are mature. The weakness
clusters hard in Feedback (no failure state at all) and Read §1.1 (failure and empty
both collapse into a plausible $0 dashboard) — the defining flaw of this surface.
This surface is thin, but three behaviors are done right and are the "don't regress" list:
Skeleton.Block height={320} and the real
BarChart is height={320} (UsageTrendChart.tsx:61,64) — same pixel height, so the
loading→content swap causes zero layout shift. This is the concrete, numeric form of
"skeleton matches the content's height" and worth citing as the ✅ beside §4.1.formatUsageValue / formatNumber / formatTokenNumber (StatCards.tsx,
UsageTrendChart.tsx, ModelBreakdown.tsx) rather than ad-hoc toLocaleString, so units
roll consistently (Read §1.5) across cards, chart axis, and table.ModelIcon + an
ellipsis model name over a secondary provider line (ModelBreakdown.tsx:24-34), so a
long model id degrades gracefully instead of breaking the row.① A failed fetch renders as a real-looking $0 dashboard — ux Read §1.1 / Feedback §4.2
🔴 useAgentUsageStats returns only { data, isLoading }; error is never read
(index.tsx:46). On any network / 500 / auth failure SWR resolves to data === undefined,
isLoading === false, and the page coerces the failure into plausible zero data:
summary={data?.summary ?? EMPTY_SUMMARY} shows $0.00 cost / 0 tokens (index.tsx:98),
buckets={data?.buckets} → an empty chart, rows={data?.byModel ?? []} → an empty table
(index.tsx:102,105). This is worse than the classic data ?? [] → <Empty> masquerade:
the failure doesn't even read as "empty", it reads as legitimate data — "this agent cost
you nothing" — with no reason and no retry anywhere. Remedy: read error; when
error, render a failed state (reason + Reload/mutate) before any aggregate is shown;
only show data on !error.
② No empty / "no usage yet" state — ux Read §1.1 🟠 When the agent genuinely has no
assistant messages in range the server returns zeros + empty arrays
(apps/server/.../usage/index.ts:253, buckets built only from rows), and the client renders
the same $0 cards, a blank chart plot, and antd Table's default "No Data"
(ModelBreakdown.tsx:65). There is no purpose-built empty page explaining what this is,
why it's empty, and a next action (e.g. "This agent hasn't run in the last 30d — start a
chat"). "No usage yet", "load failed", and "genuinely $0" are three different situations
rendered identically. Remedy: a real empty page for !error && !isLoading && totalRequests === 0, distinct from the error state in ①.
③ No-active-agent falls through to a silent zero dashboard — ux Read §1.1/§1.6 🟠 If
activeAgentId is nullish the SWR key is null → no fetch, isLoading === false, data === undefined → the full $0 dashboard renders under a null breadcrumb
(index.tsx:55-58,46). No loading, no guidance — just wrong-looking empty content. Remedy:
gate on a resolved agent id and show a loading/guidance state until it exists.
④ Range / granularity / chart-type not persisted, not in URL — Certainty (Deep-linking)
🟡 range, granularity (index.tsx:43-44) and the chart spend/token toggle
(UsageTrendChart.tsx:27) are all local useState. A reload resets to 30d / day / spend,
and the view is not shareable or bookmarkable — a cost view someone wants to send ("look at
90d") can't be linked. Remedy: lift the selections to URL search params (Deep-linking).
⑤ Dashboard dead-ends: no comparison, no export, no drill-down — class norms + Grow §5.3 🟡 Three usage/cost-dashboard class norms are absent, and one has a ready but unused building block:
StatCards uses bare StatisticCard with no prior
value (StatCards.tsx:30-67), yet the design system already ships TitleWithPercentage /
growthPercentage (components/StatisticCard/…) precisely for "▲ +12% vs previous
period". The capability exists and is simply not wired.⑥ Loading uses antd Spin, inconsistent with the chart skeleton — ux Feedback §4.1 🟡
The chart loads with a Skeleton.Block (✅) but the stat cards spin via StatisticCard's
<Spin percent="auto"/> (StatisticCard/index.tsx:166) and the table uses antd Table's
built-in spin (ModelBreakdown.tsx:70). Feedback §4.1 bans antd Spin in favor of
skeletons / project loaders, and the three blocks loading in three different visual idioms
reads as unpolished. Remedy: skeleton the cards and table to match the chart.
⑦ Trend chart is not zero-filled → a sparse, misleading timeline — pending L2 🟡 The
server creates a bucket only for days that have messages (usage/index.ts:253,274-286), so a
30-day range with 3 active days renders 3 bars, not a 30-slot timeline with gaps — the
"when this agent spent" shape is distorted and the x-axis lies about the range. L1 flags the
data shape; the visual severity is an L2 confirm. Remedy: zero-fill buckets across the
full [startAt, endAt] at the requested granularity.
ux (mandatory close). Read §1.1's existing ❌
examples are all the list form of the masquerade (data ?? [] → <Empty>). This surface
reveals a latent sub-rule: on a metrics / aggregate surface the failure default is
a zero-valued object (?? EMPTY_SUMMARY, ?? 0), so the failure renders as real-looking
data ("$0 spent"), not as "empty" — strictly worse, because nothing signals anything is
wrong. Landed by extending Read §1.1 (new paragraph + ❌ Agent stats example + a
checklist line) and mirroring one line into the SKILL Quick review, citing
AgentUsage/index.tsx:46,98.Spin), Grow §5.3 (gap ⑤, dead-end
config/metrics surface).L1-only; verdicts a later pass should confirm or quantify on this surface:
$0 cards vs a real value look identical enough to fool a user
(gap ①); check the three-idiom loading (skeleton vs two spins, gap ⑥) side by side;
narrow-width wrap of the two toolbar Segmenteds (index.tsx:64 wrap).$0
with no error and no retry, not a skeleton.Spin→value swap on the cards and the table are unmeasured.