tokentrace 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/TOKENTRACE_AGENT.md +29 -0
  3. package/app/page.tsx +17 -1
  4. package/app/query/page.tsx +292 -0
  5. package/components/charts/trend-chart.tsx +34 -2
  6. package/components/charts/trend-section.tsx +14 -2
  7. package/components/overview/anomalies-panel.tsx +116 -0
  8. package/components/repair/repair-items-table.tsx +52 -1
  9. package/components/sidebar.tsx +2 -0
  10. package/dist/runtime/agent.mjs +209 -3
  11. package/dist/runtime/anomalies.mjs +1064 -0
  12. package/dist/runtime/db-migrate.mjs +16 -0
  13. package/dist/runtime/db-seed.mjs +56 -12
  14. package/dist/runtime/digest.mjs +16 -0
  15. package/dist/runtime/doctor.mjs +16 -0
  16. package/dist/runtime/evidence.mjs +16 -0
  17. package/dist/runtime/insights.mjs +16 -0
  18. package/dist/runtime/mcp.mjs +173 -0
  19. package/dist/runtime/pricing-refresh.mjs +201 -15
  20. package/dist/runtime/query.mjs +1115 -0
  21. package/dist/runtime/repair.mjs +1117 -378
  22. package/dist/runtime/report.mjs +16 -0
  23. package/dist/runtime/reset.mjs +56 -12
  24. package/dist/runtime/review.mjs +16 -0
  25. package/dist/runtime/scan.mjs +201 -15
  26. package/dist/runtime/status.mjs +16 -0
  27. package/llms.txt +3 -0
  28. package/package.json +1 -1
  29. package/scripts/anomalies.ts +95 -0
  30. package/scripts/build-cli-runtime.mjs +2 -0
  31. package/scripts/query.ts +58 -0
  32. package/scripts/repair.ts +141 -0
  33. package/server.json +2 -2
  34. package/src/cli/commands.js +20 -0
  35. package/src/db/migrate-core.ts +16 -0
  36. package/src/lib/agent-discovery.ts +48 -0
  37. package/src/lib/anomalies-cli.ts +73 -0
  38. package/src/lib/anomaly-detection.ts +168 -0
  39. package/src/lib/cost-recalculation.ts +66 -14
  40. package/src/lib/mcp/tools.ts +78 -0
  41. package/src/lib/mcp-server.ts +102 -0
  42. package/src/lib/model-aliases/backfill.ts +136 -0
  43. package/src/lib/model-aliases/store.ts +156 -0
  44. package/src/lib/scheduled-scan.ts +18 -1
  45. package/src/lib/structured-query-cli.ts +176 -0
  46. package/src/lib/structured-query.ts +278 -0
  47. package/src/lib/unknown-cost-repair/auto-classify-cli.ts +238 -0
  48. package/src/lib/unknown-cost-repair/auto-classify.ts +175 -0
  49. package/src/lib/unknown-cost-repair/types.ts +2 -0
  50. package/src/lib/unknown-cost-repair/workbench.ts +21 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,93 @@ All notable changes to TokenTrace are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.18.1] - 2026-05-28
8
+
9
+ ### Fixed
10
+
11
+ - **Scheduled scans now coalesce concurrent runs.** `runDueScheduledScan`
12
+ fires on every overview page load; two overlapping loads (a second tab, a
13
+ quick refresh, a prefetch) previously both passed the due-check and both
14
+ called `runScan`, producing duplicate scan runs. A module-scoped in-flight
15
+ promise guard now coalesces concurrent callers into a single scan within
16
+ the server process.
17
+ - **`tokentrace repair auto-classify --apply` is now atomic.** The alias
18
+ writes and cost backfills for a batch run inside a single SQLite
19
+ transaction, and `backfillAlias` wraps its per-row updates in a transaction
20
+ too, so a failure partway can no longer leave the local database
21
+ half-applied with inaccurate reported counts.
22
+ - **`tokentrace query` surfaces range errors cleanly.** Range validation
23
+ (e.g. `--from` after `--to`, or a preset combined with explicit dates) now
24
+ prints the validation message plus usage and exits non-zero, instead of
25
+ letting a raw stack trace escape.
26
+ - **Anomaly z-scores survive JSON.** The flat-window (`mad === 0`) severe
27
+ branch reported `Infinity`, which `JSON.stringify` turned into `null` and
28
+ silently dropped the signal. It now reports a finite sentinel z-score.
29
+ - **Stricter query CLI parsing.** Empty `--flag=` values are rejected with a
30
+ clear "Missing value" message rather than flowing through as empty strings.
31
+
32
+ ### Internal
33
+
34
+ - **Test suite no longer flakes under parallel load.** CLI/MCP integration
35
+ tests spawn `bin/tokentrace.js`, which itself spawns `db-migrate` and
36
+ `db-seed`, fanning out to ~3x the core count in node processes. The Vitest
37
+ fork pool is now capped at roughly half the available cores with one
38
+ generous global timeout, removing the scattered per-test timeout overrides.
39
+ - Removed the dead `tailwind.config.ts` left over from the Tailwind 4
40
+ (CSS-first) migration.
41
+
42
+ ## [0.18.0] - 2026-05-28
43
+
44
+ ### Local intelligence
45
+
46
+ - **Anomaly detection (`tokentrace anomalies`).** A new modified-z-score (MAD)
47
+ detector scores the daily token and cost trend against a trailing window
48
+ (default 14 days) and surfaces deviations as `notable`, `high`, or `severe`.
49
+ Pure-stats; spends zero AI tokens. New CLI flags: `--window=N` (3..60),
50
+ `--metric=tokens|cost|all`, `--json`.
51
+ - **Structured query (`tokentrace query`).** A new deterministic
52
+ parameterized aggregation that lets agents ask precise local questions
53
+ without TokenTrace performing any NL parsing. Group by
54
+ `model|project|tool|session|day`, aggregate
55
+ `cost|totalTokens|interactions`, with optional preset/from/to range,
56
+ exact-match `model|project|tool` filters, and `--top` clamped to 200.
57
+ Routes through `prepareCached` for the warm-statement path.
58
+ - **Auto-classifier (`tokentrace repair auto-classify`).** Each unknown-cost
59
+ workbench group now carries a deterministic `classification` field
60
+ produced by three rules in confidence order: `exact-model` (0.95),
61
+ `family-fragment` (0.70, normalized via `modelNameCandidates`), and
62
+ `parser-source` (0.45, same source file as priced examples). The CLI
63
+ supports `--apply --min-confidence=N` (floor 0.85) which writes each
64
+ qualifying exact-model or family-fragment suggestion to a new
65
+ `model_aliases` table and backfills cost for the matching unknown-cost
66
+ interactions; `--dry-run` previews without writing. parser-source
67
+ matches are skipped from `--apply` because they have no
68
+ (provider, observed-model) pair to persist.
69
+ - **Persistent model aliases.** A new `model_aliases` table maps
70
+ `(provider_id, observed_model)` to a priced model. The cost
71
+ recalculation pass now LEFT JOINs the alias table so aliased costs
72
+ survive every re-seed, with metadata noting the rule and confidence.
73
+ - **Interactive Query page (`/query`).** A new dashboard route exposes the
74
+ structured-query surface with a server-rendered form (group-by, metric,
75
+ range preset / from / to, model/project/tool filters, top N). Results
76
+ render as a deterministic table. Same SQL path as the CLI and MCP tool.
77
+ - **Anomaly drill-down + chart markers.** Each anomaly date in the
78
+ overview Anomalies panel is now a link to `/?range=custom&from=DATE&to=DATE`
79
+ so clicking filters the entire overview to that day. The Trend chart
80
+ also renders colored Recharts `ReferenceDot` markers on flagged days
81
+ (red severe, amber high, gray notable) when the bucket is daily.
82
+ - **"Auto-classify" column in Repair Items.** The repair workbench table
83
+ now shows the suggested priced model, classification rule, and
84
+ confidence percentage alongside the existing legacy suggestion.
85
+ - **New MCP tools.** `get_anomalies`, `query_usage`, and
86
+ `get_classifications` are wired into `src/lib/mcp/tools.ts` with full
87
+ input schemas and `src/lib/mcp-server.ts` handlers that translate
88
+ structured arguments into CLI flags. Each handler is read-only and
89
+ spends zero AI tokens.
90
+ - **Agent discovery catalog.** `tokentrace agent --json` now exposes
91
+ `anomalies`, `query`, and `auto-classify` commands so MCP-capable
92
+ agents can discover the new local-intelligence surface.
93
+
7
94
  ## [0.17.0] - 2026-05-23
8
95
 
9
96
  ### Performance
@@ -92,6 +92,35 @@ curl http://127.0.0.1:3030/api/roadmap
92
92
  tokentrace evidence --json
93
93
  ```
94
94
 
95
+ ## Local Intelligence (zero AI tokens)
96
+
97
+ TokenTrace ships deterministic, local-only analytical surfaces that spend
98
+ no AI tokens. The agent on the calling side handles any natural-language
99
+ parsing; TokenTrace executes pure SQL and statistics.
100
+
101
+ Spot unusual local usage days via a modified-z-score (MAD) detector:
102
+
103
+ ```bash
104
+ tokentrace anomalies --json [--window=N] [--metric=tokens|cost|all]
105
+ ```
106
+
107
+ Run a structured aggregation by model, project, tool, session, or day:
108
+
109
+ ```bash
110
+ tokentrace query --group-by model --metric cost --range 7d --json
111
+ tokentrace query --group-by day --metric totalTokens --from 2026-05-01 --to 2026-05-15 --json
112
+ ```
113
+
114
+ Get deterministic classification suggestions for the unknown-cost queue:
115
+
116
+ ```bash
117
+ tokentrace repair auto-classify --json [--min-confidence=N]
118
+ ```
119
+
120
+ The matching MCP tools are `get_anomalies`, `query_usage`, and
121
+ `get_classifications`. All three are read-only and require no
122
+ `confirmLocalScan` acknowledgement.
123
+
95
124
  ## Guardrails
96
125
 
97
126
  - Do not run `tokentrace reset` unless the human explicitly asks to clear imported local data.
package/app/page.tsx CHANGED
@@ -2,6 +2,9 @@ import { Suspense } from "react";
2
2
  import Link from "next/link";
3
3
  import { ArrowRight } from "lucide-react";
4
4
  import { TrendSection } from "@/components/charts/trend-section-lazy";
5
+ import type { TrendAnomalyMarker } from "@/components/charts/trend-chart";
6
+ import { OverviewAnomaliesPanel } from "@/components/overview/anomalies-panel";
7
+ import { detectAnomalies } from "@/src/lib/anomaly-detection";
5
8
  import { OverviewCurrentMixPanel } from "@/components/overview/current-mix-panel";
6
9
  import { DataConfidenceStrip } from "@/components/overview/data-confidence-strip";
7
10
  import { FirstRunPanel } from "@/components/overview/first-run-panel";
@@ -41,6 +44,13 @@ async function OverviewPrimarySection({ range }: { range: ResolvedDateRange }) {
41
44
  rangeLinkParams
42
45
  } = await getOverviewPrimaryData(range);
43
46
  const repairFocusHref = mergeHrefParams("/repair", rangeLinkParams);
47
+ const anomalyReport = detectAnomalies(data.trends);
48
+ const tokenMarkers: TrendAnomalyMarker[] = anomalyReport.anomalies
49
+ .filter((entry) => entry.metric === "tokens")
50
+ .map((entry) => ({ date: entry.date, value: entry.value, severity: entry.severity }));
51
+ const costMarkers: TrendAnomalyMarker[] = anomalyReport.anomalies
52
+ .filter((entry) => entry.metric === "cost")
53
+ .map((entry) => ({ date: entry.date, value: entry.value, severity: entry.severity }));
44
54
 
45
55
  return (
46
56
  <div className="space-y-8">
@@ -63,7 +73,13 @@ async function OverviewPrimarySection({ range }: { range: ResolvedDateRange }) {
63
73
  />
64
74
  </div>
65
75
  <OverviewTrustFooter health={trust.health} pricedModelCount={trust.pricedModelCount} />
66
- <TrendSection data={data.trends} defaultWindow={trendDefaultWindow} />
76
+ <TrendSection
77
+ data={data.trends}
78
+ defaultWindow={trendDefaultWindow}
79
+ tokenMarkers={tokenMarkers}
80
+ costMarkers={costMarkers}
81
+ />
82
+ <OverviewAnomaliesPanel trends={data.trends} />
67
83
  <UsageGuardrailsPanel progress={data.usageGuardrails} />
68
84
  <OverviewRecommendationsCard recommendations={data.recommendations} />
69
85
  <OverviewCurrentMixPanel
@@ -0,0 +1,292 @@
1
+ import { PageHeader } from "@/components/ui/typography";
2
+ import { Badge } from "@/components/ui/badge";
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow
11
+ } from "@/components/ui/table";
12
+ import {
13
+ runStructuredQuery,
14
+ type StructuredQueryArgs,
15
+ type StructuredQueryGroupBy,
16
+ type StructuredQueryMetric,
17
+ type StructuredQueryRangePreset,
18
+ type StructuredQuerySort
19
+ } from "@/src/lib/structured-query";
20
+ import { formatTokens } from "@/src/lib/format";
21
+
22
+ export const dynamic = "force-dynamic";
23
+
24
+ type QueryPageProps = {
25
+ searchParams?: Promise<Record<string, string | string[] | undefined>>;
26
+ };
27
+
28
+ function readString(value: string | string[] | undefined): string | undefined {
29
+ if (Array.isArray(value)) return value[0];
30
+ return typeof value === "string" && value.trim() ? value : undefined;
31
+ }
32
+
33
+ const GROUP_BY: StructuredQueryGroupBy[] = ["model", "project", "tool", "session", "day"];
34
+ const METRIC: StructuredQueryMetric[] = ["cost", "totalTokens", "interactions"];
35
+ const PRESETS: StructuredQueryRangePreset[] = ["today", "7d", "30d", "60d", "90d", "all"];
36
+ const SORTS: StructuredQuerySort[] = ["desc", "asc"];
37
+
38
+ function formatValue(value: number, metric: StructuredQueryMetric) {
39
+ if (metric === "cost") return `$${value.toFixed(2)}`;
40
+ if (metric === "totalTokens") return formatTokens(value);
41
+ return value.toLocaleString();
42
+ }
43
+
44
+ function selectClass() {
45
+ return "w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring";
46
+ }
47
+
48
+ function inputClass() {
49
+ return "w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring";
50
+ }
51
+
52
+ function buttonClass() {
53
+ return "inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90";
54
+ }
55
+
56
+ export default async function QueryPage({ searchParams }: QueryPageProps) {
57
+ const params = (await searchParams) ?? {};
58
+ const groupBy = (readString(params.groupBy) as StructuredQueryGroupBy | undefined) ?? "model";
59
+ const metric = (readString(params.metric) as StructuredQueryMetric | undefined) ?? "cost";
60
+ const preset = readString(params.range) as StructuredQueryRangePreset | undefined;
61
+ const from = readString(params.from);
62
+ const to = readString(params.to);
63
+ const model = readString(params.model);
64
+ const project = readString(params.project);
65
+ const tool = readString(params.tool);
66
+ const topRaw = readString(params.topN);
67
+ const sort = (readString(params.sort) as StructuredQuerySort | undefined) ?? "desc";
68
+
69
+ const submitted = Boolean(params.submit);
70
+
71
+ let error: string | null = null;
72
+ let result: ReturnType<typeof runStructuredQuery> | null = null;
73
+
74
+ if (submitted) {
75
+ const args: StructuredQueryArgs = {
76
+ groupBy: GROUP_BY.includes(groupBy) ? groupBy : "model",
77
+ metric: METRIC.includes(metric) ? metric : "cost",
78
+ sort,
79
+ filters: { model, project, tool },
80
+ range: preset || from || to ? { preset, from, to } : undefined,
81
+ topN: topRaw ? Number(topRaw) : undefined
82
+ };
83
+ try {
84
+ result = runStructuredQuery(args);
85
+ } catch (caught) {
86
+ error = caught instanceof Error ? caught.message : "Query failed.";
87
+ }
88
+ }
89
+
90
+ return (
91
+ <div className="space-y-6">
92
+ <PageHeader
93
+ title="Query"
94
+ description="Deterministic local SQL aggregation. The form below runs entirely on your machine — no AI tokens are spent."
95
+ />
96
+
97
+ <Card>
98
+ <CardHeader>
99
+ <CardTitle>Run a structured query</CardTitle>
100
+ <CardDescription>
101
+ Group local interactions by model, project, tool, session, or day and aggregate cost,
102
+ total tokens, or interaction count.
103
+ </CardDescription>
104
+ </CardHeader>
105
+ <CardContent>
106
+ <form method="get" className="grid gap-4 md:grid-cols-3">
107
+ <label className="space-y-1 text-sm">
108
+ <span className="font-medium">Group by</span>
109
+ <select name="groupBy" defaultValue={groupBy} className={selectClass()}>
110
+ {GROUP_BY.map((value) => (
111
+ <option key={value} value={value}>
112
+ {value}
113
+ </option>
114
+ ))}
115
+ </select>
116
+ </label>
117
+ <label className="space-y-1 text-sm">
118
+ <span className="font-medium">Metric</span>
119
+ <select name="metric" defaultValue={metric} className={selectClass()}>
120
+ {METRIC.map((value) => (
121
+ <option key={value} value={value}>
122
+ {value}
123
+ </option>
124
+ ))}
125
+ </select>
126
+ </label>
127
+ <label className="space-y-1 text-sm">
128
+ <span className="font-medium">Sort</span>
129
+ <select name="sort" defaultValue={sort} className={selectClass()}>
130
+ {SORTS.map((value) => (
131
+ <option key={value} value={value}>
132
+ {value}
133
+ </option>
134
+ ))}
135
+ </select>
136
+ </label>
137
+ <label className="space-y-1 text-sm">
138
+ <span className="font-medium">Preset range</span>
139
+ <select name="range" defaultValue={preset ?? ""} className={selectClass()}>
140
+ <option value="">(none)</option>
141
+ {PRESETS.map((value) => (
142
+ <option key={value} value={value}>
143
+ {value}
144
+ </option>
145
+ ))}
146
+ </select>
147
+ </label>
148
+ <label className="space-y-1 text-sm">
149
+ <span className="font-medium">From (YYYY-MM-DD)</span>
150
+ <input
151
+ type="text"
152
+ name="from"
153
+ defaultValue={from ?? ""}
154
+ placeholder="2026-05-01"
155
+ className={inputClass()}
156
+ />
157
+ </label>
158
+ <label className="space-y-1 text-sm">
159
+ <span className="font-medium">To (YYYY-MM-DD, exclusive)</span>
160
+ <input
161
+ type="text"
162
+ name="to"
163
+ defaultValue={to ?? ""}
164
+ placeholder="2026-05-15"
165
+ className={inputClass()}
166
+ />
167
+ </label>
168
+ <label className="space-y-1 text-sm">
169
+ <span className="font-medium">Model filter</span>
170
+ <input type="text" name="model" defaultValue={model ?? ""} className={inputClass()} />
171
+ </label>
172
+ <label className="space-y-1 text-sm">
173
+ <span className="font-medium">Project filter</span>
174
+ <input
175
+ type="text"
176
+ name="project"
177
+ defaultValue={project ?? ""}
178
+ className={inputClass()}
179
+ />
180
+ </label>
181
+ <label className="space-y-1 text-sm">
182
+ <span className="font-medium">Tool filter</span>
183
+ <input type="text" name="tool" defaultValue={tool ?? ""} className={inputClass()} />
184
+ </label>
185
+ <label className="space-y-1 text-sm">
186
+ <span className="font-medium">Top N (1..200)</span>
187
+ <input
188
+ type="number"
189
+ name="topN"
190
+ min={1}
191
+ max={200}
192
+ defaultValue={topRaw ?? "20"}
193
+ className={inputClass()}
194
+ />
195
+ </label>
196
+ <div className="md:col-span-3">
197
+ <button type="submit" name="submit" value="1" className={buttonClass()}>
198
+ Run query
199
+ </button>
200
+ </div>
201
+ </form>
202
+ </CardContent>
203
+ </Card>
204
+
205
+ {error ? (
206
+ <Card>
207
+ <CardHeader>
208
+ <CardTitle>Query error</CardTitle>
209
+ <CardDescription>The structured-query validator rejected the arguments.</CardDescription>
210
+ </CardHeader>
211
+ <CardContent>
212
+ <p className="text-sm text-destructive">{error}</p>
213
+ </CardContent>
214
+ </Card>
215
+ ) : null}
216
+
217
+ {result ? (
218
+ <Card>
219
+ <CardHeader className="flex flex-col gap-3 pb-3 lg:flex-row lg:items-start lg:justify-between">
220
+ <div>
221
+ <CardTitle>
222
+ {result.rows.length === 0 ? "No rows" : `${result.rows.length} rows`}
223
+ </CardTitle>
224
+ <CardDescription>
225
+ Group by <code>{result.groupBy}</code>, metric <code>{result.metric}</code>, sort{" "}
226
+ <code>{result.sort}</code>. {result.range.preset ? `Range: ${result.range.preset}. ` : ""}
227
+ {result.range.from || result.range.to
228
+ ? `Window: ${result.range.from ?? "—"} → ${result.range.to ?? "—"}. `
229
+ : ""}
230
+ Showing {result.rows.length} of {result.totalGroups} groups
231
+ {result.truncated ? " (truncated by topN)" : ""}.
232
+ </CardDescription>
233
+ </div>
234
+ <div className="flex gap-2">
235
+ {result.filters.model ? <Badge variant="secondary">model={result.filters.model}</Badge> : null}
236
+ {result.filters.project ? <Badge variant="secondary">project={result.filters.project}</Badge> : null}
237
+ {result.filters.tool ? <Badge variant="secondary">tool={result.filters.tool}</Badge> : null}
238
+ </div>
239
+ </CardHeader>
240
+ <CardContent>
241
+ {result.rows.length === 0 ? (
242
+ <p className="text-sm text-muted-foreground">
243
+ No groups matched. Try a different range or remove filters.
244
+ </p>
245
+ ) : (
246
+ <div className="overflow-x-auto">
247
+ <Table>
248
+ <TableHeader>
249
+ <TableRow>
250
+ <TableHead>{result.groupBy}</TableHead>
251
+ <TableHead className="text-right">{result.metric}</TableHead>
252
+ <TableHead className="text-right">interactions</TableHead>
253
+ <TableHead className="text-right">totalTokens</TableHead>
254
+ <TableHead className="text-right">cost</TableHead>
255
+ </TableRow>
256
+ </TableHeader>
257
+ <TableBody>
258
+ {result.rows.map((row) => (
259
+ <TableRow key={row.group}>
260
+ <TableCell className="font-medium">{row.group}</TableCell>
261
+ <TableCell className="text-right">
262
+ {formatValue(row.value, result.metric)}
263
+ </TableCell>
264
+ <TableCell className="text-right">{row.interactions.toLocaleString()}</TableCell>
265
+ <TableCell className="text-right">{formatTokens(row.totalTokens)}</TableCell>
266
+ <TableCell className="text-right">${row.cost.toFixed(2)}</TableCell>
267
+ </TableRow>
268
+ ))}
269
+ </TableBody>
270
+ </Table>
271
+ </div>
272
+ )}
273
+ </CardContent>
274
+ </Card>
275
+ ) : null}
276
+
277
+ {!submitted && !error ? (
278
+ <Card>
279
+ <CardHeader>
280
+ <CardTitle>About this page</CardTitle>
281
+ <CardDescription>
282
+ The form submits a GET request with structured arguments. The same query is available
283
+ from the CLI as <code>tokentrace query --group-by ... --metric ... --json</code> and
284
+ through the MCP <code>query_usage</code> tool. All three paths execute the same
285
+ deterministic SQL — zero AI tokens are spent.
286
+ </CardDescription>
287
+ </CardHeader>
288
+ </Card>
289
+ ) : null}
290
+ </div>
291
+ );
292
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import { useMemo, useState } from "react";
5
- import { Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
5
+ import { Area, AreaChart, CartesianGrid, ReferenceDot, Tooltip, XAxis, YAxis } from "recharts";
6
6
  import type { TrendPoint } from "@/src/lib/analytics";
7
7
  import { formatCurrency, formatShortDate, formatTokens } from "@/src/lib/format";
8
8
  import { Button } from "@/components/ui/button";
@@ -118,6 +118,18 @@ function bucketFor(date: string, period: TrendBucket) {
118
118
  return date;
119
119
  }
120
120
 
121
+ export type TrendAnomalyMarker = {
122
+ date: string;
123
+ value: number;
124
+ severity: "notable" | "high" | "severe";
125
+ };
126
+
127
+ function severityColor(severity: TrendAnomalyMarker["severity"]) {
128
+ if (severity === "severe") return "#dc2626";
129
+ if (severity === "high") return "#f59e0b";
130
+ return "#a3a3a3";
131
+ }
132
+
121
133
  export function TrendChart({
122
134
  data,
123
135
  metric,
@@ -125,7 +137,8 @@ export function TrendChart({
125
137
  defaultWindow = "60d",
126
138
  period: controlledPeriod,
127
139
  trendWindow: controlledTrendWindow,
128
- showControls = true
140
+ showControls = true,
141
+ markers
129
142
  }: {
130
143
  data: TrendPoint[];
131
144
  metric: "totalTokens" | "cost";
@@ -134,6 +147,7 @@ export function TrendChart({
134
147
  period?: TrendBucket;
135
148
  trendWindow?: TrendWindow;
136
149
  showControls?: boolean;
150
+ markers?: TrendAnomalyMarker[];
137
151
  }) {
138
152
  const [internalPeriod, setInternalPeriod] = useState<TrendBucket>("daily");
139
153
  const [internalTrendWindow, setInternalTrendWindow] = useState<TrendWindow>(defaultWindow);
@@ -228,6 +242,24 @@ export function TrendChart({
228
242
  fill={`url(#trend-${metric})`}
229
243
  strokeWidth={2}
230
244
  />
245
+ {period === "daily" && markers
246
+ ? markers
247
+ .filter((marker) =>
248
+ chartData.some((point) => point.date === marker.date)
249
+ )
250
+ .map((marker) => (
251
+ <ReferenceDot
252
+ key={`${marker.date}:${metric}`}
253
+ x={marker.date}
254
+ y={marker.value}
255
+ r={5}
256
+ fill={severityColor(marker.severity)}
257
+ stroke="#ffffff"
258
+ strokeWidth={2}
259
+ ifOverflow="extendDomain"
260
+ />
261
+ ))
262
+ : null}
231
263
  </AreaChart>
232
264
  ) : (
233
265
  <ChartSkeleton />
@@ -5,7 +5,13 @@ import { useState } from "react";
5
5
  import type { TrendPoint } from "@/src/lib/analytics";
6
6
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
7
  import { Badge } from "@/components/ui/badge";
8
- import { TrendChart, TrendControls, type TrendBucket, type TrendWindow } from "@/components/charts/trend-chart";
8
+ import {
9
+ TrendChart,
10
+ TrendControls,
11
+ type TrendAnomalyMarker,
12
+ type TrendBucket,
13
+ type TrendWindow
14
+ } from "@/components/charts/trend-chart";
9
15
 
10
16
  function trendWindowLabel(window: TrendWindow) {
11
17
  if (window === "30d") return "Showing latest 30 days";
@@ -16,10 +22,14 @@ function trendWindowLabel(window: TrendWindow) {
16
22
 
17
23
  export function TrendSection({
18
24
  data,
19
- defaultWindow
25
+ defaultWindow,
26
+ tokenMarkers,
27
+ costMarkers
20
28
  }: {
21
29
  data: TrendPoint[];
22
30
  defaultWindow: TrendWindow;
31
+ tokenMarkers?: TrendAnomalyMarker[];
32
+ costMarkers?: TrendAnomalyMarker[];
23
33
  }) {
24
34
  const [period, setPeriod] = useState<TrendBucket>("daily");
25
35
  const [trendWindow, setTrendWindow] = useState<TrendWindow>(defaultWindow);
@@ -65,6 +75,7 @@ export function TrendSection({
65
75
  period={period}
66
76
  trendWindow={trendWindow}
67
77
  showControls={false}
78
+ markers={tokenMarkers}
68
79
  />
69
80
  </CardContent>
70
81
  </Card>
@@ -81,6 +92,7 @@ export function TrendSection({
81
92
  period={period}
82
93
  trendWindow={trendWindow}
83
94
  showControls={false}
95
+ markers={costMarkers}
84
96
  />
85
97
  </CardContent>
86
98
  </Card>