@oh-my-pi/omp-stats 16.0.4 → 16.0.6

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 (107) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/build.ts +11 -0
  3. package/dist/client/index.css +1 -1
  4. package/dist/client/index.html +11 -0
  5. package/dist/client/index.js +108 -108
  6. package/dist/client/styles.css +1070 -631
  7. package/dist/types/client/api.d.ts +19 -10
  8. package/dist/types/client/app/AppLayout.d.ts +16 -0
  9. package/dist/types/client/app/NavRail.d.ts +7 -0
  10. package/dist/types/client/app/RangeControl.d.ts +7 -0
  11. package/dist/types/client/app/SyncButton.d.ts +14 -0
  12. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  13. package/dist/types/client/app/TopBar.d.ts +15 -0
  14. package/dist/types/client/app/routes.d.ts +12 -0
  15. package/dist/types/client/components/chart-shared.d.ts +26 -40
  16. package/dist/types/client/components/models-table-shared.d.ts +20 -40
  17. package/dist/types/client/data/charts.d.ts +1 -0
  18. package/dist/types/client/data/formatters.d.ts +7 -0
  19. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  20. package/dist/types/client/data/useResource.d.ts +13 -0
  21. package/dist/types/client/data/view-models.d.ts +37 -0
  22. package/dist/types/client/index.d.ts +1 -0
  23. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  24. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  25. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  26. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  27. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  28. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  29. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  30. package/dist/types/client/routes/index.d.ts +7 -0
  31. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  32. package/dist/types/client/ui/DataTable.d.ts +17 -0
  33. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  34. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  35. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  36. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  37. package/dist/types/client/ui/Panel.d.ts +7 -0
  38. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  39. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  40. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  41. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  42. package/dist/types/client/ui/index.d.ts +11 -0
  43. package/dist/types/client/useSystemTheme.d.ts +9 -0
  44. package/package.json +4 -4
  45. package/src/aggregator.ts +4 -3
  46. package/src/client/App.tsx +89 -207
  47. package/src/client/api.ts +55 -37
  48. package/src/client/app/AppLayout.tsx +93 -0
  49. package/src/client/app/NavRail.tsx +44 -0
  50. package/src/client/app/RangeControl.tsx +39 -0
  51. package/src/client/app/SyncButton.tsx +75 -0
  52. package/src/client/app/ThemeToggle.tsx +37 -0
  53. package/src/client/app/TopBar.tsx +73 -0
  54. package/src/client/app/routes.ts +50 -0
  55. package/src/client/components/chart-shared.tsx +28 -91
  56. package/src/client/components/models-table-shared.tsx +9 -29
  57. package/src/client/components/range-meta.ts +3 -2
  58. package/src/client/data/charts.ts +14 -0
  59. package/src/client/data/formatters.ts +38 -0
  60. package/src/client/data/useHashRoute.ts +85 -0
  61. package/src/client/data/useResource.ts +154 -0
  62. package/src/client/data/view-models.ts +178 -0
  63. package/src/client/index.tsx +4 -0
  64. package/src/client/routes/BehaviorRoute.tsx +623 -0
  65. package/src/client/routes/CostsRoute.tsx +234 -0
  66. package/src/client/routes/ErrorsRoute.tsx +118 -0
  67. package/src/client/routes/ModelsRoute.tsx +430 -0
  68. package/src/client/routes/OverviewRoute.tsx +332 -0
  69. package/src/client/routes/ProjectsRoute.tsx +163 -0
  70. package/src/client/routes/RequestsRoute.tsx +123 -0
  71. package/src/client/routes/index.ts +7 -0
  72. package/src/client/styles.css +1242 -225
  73. package/src/client/ui/AsyncBoundary.tsx +54 -0
  74. package/src/client/ui/DataTable.tsx +122 -0
  75. package/src/client/ui/EmptyState.tsx +16 -0
  76. package/src/client/ui/ErrorState.tsx +25 -0
  77. package/src/client/ui/JsonBlock.tsx +75 -0
  78. package/src/client/ui/MetricCluster.tsx +67 -0
  79. package/src/client/ui/Panel.tsx +24 -0
  80. package/src/client/ui/RequestDrawer.tsx +208 -0
  81. package/src/client/ui/SegmentedControl.tsx +36 -0
  82. package/src/client/ui/Skeleton.tsx +17 -0
  83. package/src/client/ui/StatusPill.tsx +15 -0
  84. package/src/client/ui/index.ts +11 -0
  85. package/src/client/useSystemTheme.ts +73 -17
  86. package/dist/types/client/components/BehaviorChart.d.ts +0 -6
  87. package/dist/types/client/components/BehaviorModelsTable.d.ts +0 -7
  88. package/dist/types/client/components/BehaviorSummary.d.ts +0 -7
  89. package/dist/types/client/components/ChartsContainer.d.ts +0 -7
  90. package/dist/types/client/components/CostChart.d.ts +0 -6
  91. package/dist/types/client/components/CostSummary.d.ts +0 -6
  92. package/dist/types/client/components/Header.d.ts +0 -12
  93. package/dist/types/client/components/ModelsTable.d.ts +0 -8
  94. package/dist/types/client/components/RequestDetail.d.ts +0 -6
  95. package/dist/types/client/components/RequestList.d.ts +0 -8
  96. package/dist/types/client/components/StatsGrid.d.ts +0 -6
  97. package/src/client/components/BehaviorChart.tsx +0 -189
  98. package/src/client/components/BehaviorModelsTable.tsx +0 -342
  99. package/src/client/components/BehaviorSummary.tsx +0 -95
  100. package/src/client/components/ChartsContainer.tsx +0 -221
  101. package/src/client/components/CostChart.tsx +0 -171
  102. package/src/client/components/CostSummary.tsx +0 -53
  103. package/src/client/components/Header.tsx +0 -72
  104. package/src/client/components/ModelsTable.tsx +0 -265
  105. package/src/client/components/RequestDetail.tsx +0 -172
  106. package/src/client/components/RequestList.tsx +0 -73
  107. package/src/client/components/StatsGrid.tsx +0 -135
@@ -0,0 +1,623 @@
1
+ import { format } from "date-fns";
2
+ import { useMemo, useState } from "react";
3
+ import { Bar, Line } from "react-chartjs-2";
4
+ import { getBehaviorDashboardStats } from "../api";
5
+ import {
6
+ barDatasetStyle,
7
+ buildAggregateTimeSeries,
8
+ buildSharedPlugins,
9
+ buildSharedScales,
10
+ buildTopNByModelSeries,
11
+ CHART_THEMES,
12
+ lineDatasetStyle,
13
+ MODEL_COLORS,
14
+ styleDatasets,
15
+ } from "../components/chart-shared";
16
+ import {
17
+ DetailChartEmpty,
18
+ detailChartPlugins,
19
+ detailChartScalesSingleAxis,
20
+ ExpandableModelRow,
21
+ lineSeriesStyle,
22
+ MiniSparkline,
23
+ ModelNameCell,
24
+ ModelTableBody,
25
+ ModelTableHeader,
26
+ ModelTableShell,
27
+ TABLE_CHART_THEMES,
28
+ type TableChartTheme,
29
+ TrendEmpty,
30
+ } from "../components/models-table-shared";
31
+ import { formatInteger } from "../data/formatters";
32
+ import { useResource } from "../data/useResource";
33
+ import { buildBehaviorSummary } from "../data/view-models";
34
+ import type { BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, TimeRange } from "../types";
35
+ import { AsyncBoundary, Panel, SegmentedControl } from "../ui";
36
+ import { useSystemTheme } from "../useSystemTheme";
37
+
38
+ export interface BehaviorRouteProps {
39
+ active: boolean;
40
+ range: TimeRange;
41
+ refreshTrigger: number;
42
+ }
43
+
44
+ export function BehaviorRoute({ active, range, refreshTrigger }: BehaviorRouteProps) {
45
+ const {
46
+ data: stats,
47
+ error,
48
+ loading,
49
+ } = useResource(["behavior", range, refreshTrigger], signal => getBehaviorDashboardStats(range, signal), {
50
+ pollMs: 30000,
51
+ enabled: active,
52
+ });
53
+
54
+ return (
55
+ <div className="stats-route-container space-y-6">
56
+ <AsyncBoundary loading={loading} error={error} data={stats}>
57
+ {stats && (
58
+ <>
59
+ <BehaviorSummaryPanel overall={stats.overall} behaviorSeries={stats.behaviorSeries} />
60
+ <BehaviorChartPanel behaviorSeries={stats.behaviorSeries} />
61
+ <BehaviorModelsTable models={stats.byModel} behaviorSeries={stats.behaviorSeries} />
62
+ </>
63
+ )}
64
+ </AsyncBoundary>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ function perMsg(total: number, messages: number): string | undefined {
70
+ if (messages <= 0) return undefined;
71
+ return `${(total / messages).toFixed(2)} / msg`;
72
+ }
73
+
74
+ function BehaviorSummaryPanel({
75
+ overall,
76
+ behaviorSeries,
77
+ }: {
78
+ overall: BehaviorOverallStats;
79
+ behaviorSeries: BehaviorTimeSeriesPoint[];
80
+ }) {
81
+ const summary = useMemo(() => buildBehaviorSummary(overall, behaviorSeries), [overall, behaviorSeries]);
82
+ const messages = overall.totalMessages;
83
+
84
+ const cards = [
85
+ {
86
+ label: "User Messages",
87
+ value: formatInteger(overall.totalMessages),
88
+ sub: messages > 0 ? "in range" : undefined,
89
+ },
90
+ {
91
+ label: "Yelling (CAPS)",
92
+ value: formatInteger(overall.totalYelling),
93
+ sub: perMsg(overall.totalYelling, messages),
94
+ },
95
+ {
96
+ label: "Profanity Hits",
97
+ value: formatInteger(overall.totalProfanity),
98
+ sub: perMsg(overall.totalProfanity, messages),
99
+ },
100
+ {
101
+ label: "Anguish Signals",
102
+ value: formatInteger(overall.totalAnguish),
103
+ sub: perMsg(overall.totalAnguish, messages),
104
+ },
105
+ {
106
+ label: "Friction Signals",
107
+ value: formatInteger(summary.totalFrustration),
108
+ sub: perMsg(summary.totalFrustration, messages),
109
+ },
110
+ {
111
+ label: "Highest Friction Model",
112
+ value: summary.highestFrictionModel?.model ?? "—",
113
+ sub: summary.highestFrictionModel ? `${formatInteger(summary.highestFrictionModel.score)} hits` : undefined,
114
+ },
115
+ ];
116
+
117
+ return (
118
+ <div className="grid grid-cols-2 md:grid-cols-6 gap-4">
119
+ {cards.map(card => (
120
+ <Panel key={card.label} className="stats-behavior-summary-card py-3 px-4">
121
+ <p className="text-xs stats-text-muted mb-1 font-medium truncate">{card.label}</p>
122
+ <p className="text-lg font-bold stats-text-primary truncate" title={card.value}>
123
+ {card.value}
124
+ </p>
125
+ {card.sub && <p className="text-xs stats-text-muted mt-0.5">{card.sub}</p>}
126
+ </Panel>
127
+ ))}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ const METRIC_OPTIONS = [
133
+ { value: "yelling", label: "CAPS", title: "Yelling (CAPS)" },
134
+ { value: "profanity", label: "Profanity", title: "Profanity" },
135
+ { value: "anguish", label: "Anguish", title: "Anguish (!!!, nooo, dude, ..)" },
136
+ { value: "negation", label: "Negation", title: "Negation (no/nope/wrong)" },
137
+ {
138
+ value: "repetition",
139
+ label: "Repetition",
140
+ title: "Repetition (i meant, still doesnt)",
141
+ },
142
+ { value: "blame", label: "Blame", title: "Blame (you didnt, stop X-ing)" },
143
+ {
144
+ value: "frustration",
145
+ label: "Frustration",
146
+ title: "Frustration (neg + rep + blame)",
147
+ },
148
+ { value: "total", label: "All", title: "All signals combined" },
149
+ ] as const;
150
+
151
+ type Metric = (typeof METRIC_OPTIONS)[number]["value"];
152
+
153
+ function formatRateAxis(value: number): string {
154
+ if (!Number.isFinite(value)) return "-";
155
+ if (value === 0) return "0%";
156
+ if (Math.abs(value) < 1) return `${value.toFixed(1)}%`;
157
+ return `${value.toFixed(0)}%`;
158
+ }
159
+
160
+ function pointHits(point: BehaviorTimeSeriesPoint, metric: Metric): number {
161
+ if (metric === "frustration") {
162
+ return point.negation + point.repetition + point.blame;
163
+ }
164
+ if (metric === "total") {
165
+ return point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
166
+ }
167
+ return point[metric];
168
+ }
169
+
170
+ function ratePercent(hits: number, messages: number): number {
171
+ if (messages <= 0) return 0;
172
+ return (hits / messages) * 100;
173
+ }
174
+
175
+ interface DailyBucket {
176
+ hits: number;
177
+ messages: number;
178
+ }
179
+
180
+ function BehaviorChartPanel({ behaviorSeries }: { behaviorSeries: BehaviorTimeSeriesPoint[] }) {
181
+ const [byModel, setByModel] = useState(false);
182
+ const [metric, setMetric] = useState<Metric>("total");
183
+ const theme = useSystemTheme();
184
+ const chartTheme = CHART_THEMES[theme];
185
+
186
+ const chartData = useMemo(() => {
187
+ if (byModel) {
188
+ return buildTopNByModelSeries<BehaviorTimeSeriesPoint, DailyBucket>(behaviorSeries, {
189
+ rankWeight: point => point.messages,
190
+ initBucket: () => ({ hits: 0, messages: 0 }),
191
+ accumulate: (bucket, point) => {
192
+ bucket.hits += pointHits(point, metric);
193
+ bucket.messages += point.messages;
194
+ },
195
+ bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages),
196
+ });
197
+ }
198
+ const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.title ?? "Hits";
199
+ return buildAggregateTimeSeries<BehaviorTimeSeriesPoint, DailyBucket>(behaviorSeries, metricLabel, {
200
+ initBucket: () => ({ hits: 0, messages: 0 }),
201
+ accumulate: (bucket, point) => {
202
+ bucket.hits += pointHits(point, metric);
203
+ bucket.messages += point.messages;
204
+ },
205
+ bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages),
206
+ });
207
+ }, [behaviorSeries, byModel, metric]);
208
+
209
+ const sharedPlugins = useMemo(() => {
210
+ return buildSharedPlugins({
211
+ chartTheme,
212
+ showLegend: byModel,
213
+ defaultLabel: "Hits",
214
+ formatValue: formatRateAxis,
215
+ });
216
+ }, [chartTheme, byModel]);
217
+
218
+ const { sharedScaleBase, yScale } = useMemo(() => {
219
+ return buildSharedScales({ chartTheme, formatY: formatRateAxis });
220
+ }, [chartTheme]);
221
+
222
+ const metricLabel = useMemo(() => {
223
+ return METRIC_OPTIONS.find(m => m.value === metric)?.title ?? "";
224
+ }, [metric]);
225
+
226
+ const lineData = useMemo(() => {
227
+ if (!byModel) return null;
228
+ return {
229
+ labels: chartData.labels,
230
+ datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
231
+ };
232
+ }, [chartData, byModel]);
233
+
234
+ const lineOptions = useMemo(() => {
235
+ return {
236
+ responsive: true,
237
+ maintainAspectRatio: false,
238
+ interaction: { mode: "index" as const, intersect: false },
239
+ plugins: sharedPlugins,
240
+ scales: { x: sharedScaleBase, y: yScale },
241
+ };
242
+ }, [sharedPlugins, sharedScaleBase, yScale]);
243
+
244
+ const barData = useMemo(() => {
245
+ if (byModel) return null;
246
+ return {
247
+ labels: chartData.labels,
248
+ datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
249
+ };
250
+ }, [chartData, byModel]);
251
+
252
+ const barOptions = useMemo(() => {
253
+ return {
254
+ responsive: true,
255
+ maintainAspectRatio: false,
256
+ interaction: { mode: "index" as const, intersect: false },
257
+ plugins: sharedPlugins,
258
+ scales: {
259
+ x: { ...sharedScaleBase, stacked: true },
260
+ y: { ...yScale, stacked: true },
261
+ },
262
+ layout: { padding: { top: 8 } },
263
+ };
264
+ }, [sharedPlugins, sharedScaleBase, yScale]);
265
+
266
+ const byModelOptions = [
267
+ { value: false, label: "All Models" },
268
+ { value: true, label: "By Model" },
269
+ ];
270
+
271
+ return (
272
+ <Panel
273
+ title="User Friction Signals"
274
+ subtitle={`${metricLabel} as % of user messages per day`}
275
+ actions={
276
+ <div className="flex items-center gap-3 flex-wrap">
277
+ <SegmentedControl
278
+ options={METRIC_OPTIONS.map(o => ({
279
+ value: o.value,
280
+ label: o.label,
281
+ title: o.title,
282
+ }))}
283
+ value={metric}
284
+ onChange={setMetric}
285
+ />
286
+ <SegmentedControl options={byModelOptions} value={byModel} onChange={setByModel} />
287
+ </div>
288
+ }
289
+ >
290
+ <div className="h-[300px]">
291
+ {chartData.labels.length === 0 ? (
292
+ <div className="h-full flex items-center justify-center text-stats-muted text-sm">
293
+ No friction signal data available
294
+ </div>
295
+ ) : byModel && lineData ? (
296
+ <Line data={lineData} options={lineOptions} />
297
+ ) : barData ? (
298
+ <Bar data={barData} options={barOptions} />
299
+ ) : null}
300
+ </div>
301
+ </Panel>
302
+ );
303
+ }
304
+
305
+ const TABLE_GRID_TEMPLATE = "2fr 0.9fr 0.8fr 0.8fr 0.8fr 0.9fr 0.8fr 140px 40px";
306
+
307
+ function totalHitRate(model: BehaviorModelStats): number {
308
+ if (model.totalMessages === 0) return 0;
309
+ const hits =
310
+ model.totalYelling +
311
+ model.totalProfanity +
312
+ model.totalAnguish +
313
+ model.totalNegation +
314
+ model.totalRepetition +
315
+ model.totalBlame;
316
+ return hits / model.totalMessages;
317
+ }
318
+
319
+ function formatRate(total: number, messages: number): string {
320
+ if (messages === 0) return "-";
321
+ const pct = (total / messages) * 100;
322
+ if (pct === 0) return "0%";
323
+ if (pct < 1) return `${pct.toFixed(1)}%`;
324
+ return `${pct.toFixed(0)}%`;
325
+ }
326
+
327
+ function BehaviorModelsTable({
328
+ models,
329
+ behaviorSeries,
330
+ }: {
331
+ models: BehaviorModelStats[];
332
+ behaviorSeries: BehaviorTimeSeriesPoint[];
333
+ }) {
334
+ const [expandedKey, setExpandedKey] = useState<string | null>(null);
335
+ const theme = useSystemTheme();
336
+ const chartTheme = TABLE_CHART_THEMES[theme];
337
+
338
+ const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]);
339
+
340
+ const sortedModels = useMemo(() => {
341
+ return [...models].sort((a, b) => {
342
+ if (b.totalMessages !== a.totalMessages) {
343
+ return b.totalMessages - a.totalMessages;
344
+ }
345
+ return totalHitRate(b) - totalHitRate(a);
346
+ });
347
+ }, [models]);
348
+
349
+ return (
350
+ <ModelTableShell title="Behavior Signals by Model" subtitle="Rates are per user message">
351
+ <ModelTableHeader
352
+ gridTemplate={TABLE_GRID_TEMPLATE}
353
+ columns={[
354
+ { label: "Model" },
355
+ { label: "Messages", align: "right" },
356
+ { label: "CAPS %", align: "right" },
357
+ { label: "Profanity %", align: "right" },
358
+ { label: "Anguish %", align: "right" },
359
+ { label: "Frustration %", align: "right" },
360
+ { label: "Hits %", align: "right" },
361
+ { label: "Trend", align: "center" },
362
+ ]}
363
+ />
364
+
365
+ <ModelTableBody>
366
+ {sortedModels.map((model, index) => {
367
+ const key = `${model.model}::${model.provider}`;
368
+ const trend = trendByKey.get(key)?.data ?? [];
369
+ const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
370
+ const isExpanded = expandedKey === key;
371
+ const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame;
372
+ const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration;
373
+
374
+ return (
375
+ <ExpandableModelRow
376
+ key={key}
377
+ gridTemplate={TABLE_GRID_TEMPLATE}
378
+ isExpanded={isExpanded}
379
+ onToggle={() => setExpandedKey(isExpanded ? null : key)}
380
+ cells={[
381
+ <ModelNameCell key="name" model={model.model} provider={model.provider} />,
382
+ <div key="messages" className="text-right text-[var(--text-secondary)] font-mono text-sm">
383
+ {formatInteger(model.totalMessages)}
384
+ </div>,
385
+ <div key="caps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
386
+ {formatRate(model.totalYelling, model.totalMessages)}
387
+ </div>,
388
+ <div key="profanity" className="text-right text-[var(--text-secondary)] font-mono text-sm">
389
+ {formatRate(model.totalProfanity, model.totalMessages)}
390
+ </div>,
391
+ <div key="anguish" className="text-right text-[var(--text-secondary)] font-mono text-sm">
392
+ {formatRate(model.totalAnguish, model.totalMessages)}
393
+ </div>,
394
+ <div key="frustration" className="text-right text-[var(--text-secondary)] font-mono text-sm">
395
+ {formatRate(totalFrustration, model.totalMessages)}
396
+ </div>,
397
+ <div key="hits" className="text-right text-[var(--text-secondary)] font-mono text-sm">
398
+ {formatRate(totalHits, model.totalMessages)}
399
+ </div>,
400
+ ]}
401
+ trendCell={
402
+ trend.length === 0 ? (
403
+ <TrendEmpty />
404
+ ) : (
405
+ <MiniSparkline
406
+ timestamps={trend.map(d => d.timestamp)}
407
+ values={trend.map(d => d.total)}
408
+ color={trendColor}
409
+ />
410
+ )
411
+ }
412
+ expandedContent={
413
+ <div className="grid gap-4" style={{ gridTemplateColumns: "220px 1fr" }}>
414
+ <div className="space-y-4 text-sm">
415
+ <DetailRow
416
+ label="Yelling (CAPS)"
417
+ total={model.totalYelling}
418
+ messages={model.totalMessages}
419
+ valueClass="text-[#ed4abf]"
420
+ />
421
+ <DetailRow
422
+ label="Profanity"
423
+ total={model.totalProfanity}
424
+ messages={model.totalMessages}
425
+ valueClass="text-[#ff6b7d]"
426
+ />
427
+ <DetailRow
428
+ label="Anguish (!!!, nooo, dude, ..)"
429
+ total={model.totalAnguish}
430
+ messages={model.totalMessages}
431
+ valueClass="text-[#9b4dff]"
432
+ />
433
+ <DetailRow
434
+ label="Negation (no/nope/wrong)"
435
+ total={model.totalNegation}
436
+ messages={model.totalMessages}
437
+ valueClass="text-[#5ad8e6]"
438
+ />
439
+ <DetailRow
440
+ label="Repetition (i meant, still doesnt)"
441
+ total={model.totalRepetition}
442
+ messages={model.totalMessages}
443
+ valueClass="text-[#5ad8e6]"
444
+ />
445
+ <DetailRow
446
+ label="Blame (you didnt, stop X-ing)"
447
+ total={model.totalBlame}
448
+ messages={model.totalMessages}
449
+ valueClass="text-[#5ad8e6]"
450
+ />
451
+ <DetailRow
452
+ label="Avg chars / msg"
453
+ total={model.totalChars}
454
+ messages={model.totalMessages}
455
+ valueClass="stats-text-secondary"
456
+ mode="average"
457
+ />
458
+ </div>
459
+ <div className="h-[200px]">
460
+ {trend.length === 0 ? (
461
+ <DetailChartEmpty />
462
+ ) : (
463
+ <BreakdownChart data={trend} chartTheme={chartTheme} />
464
+ )}
465
+ </div>
466
+ </div>
467
+ }
468
+ />
469
+ );
470
+ })}
471
+ {sortedModels.length === 0 ? (
472
+ <div className="border-t border-[var(--border-subtle)] px-5 py-8 text-center text-[var(--text-muted)] text-sm">
473
+ No user behavior recorded for this range yet.
474
+ </div>
475
+ ) : null}
476
+ </ModelTableBody>
477
+ </ModelTableShell>
478
+ );
479
+ }
480
+
481
+ function DetailRow({
482
+ label,
483
+ total,
484
+ messages,
485
+ valueClass,
486
+ mode = "rate",
487
+ }: {
488
+ label: string;
489
+ total: number;
490
+ messages: number;
491
+ valueClass: string;
492
+ mode?: "rate" | "average";
493
+ }) {
494
+ const perMsgLabel = mode === "rate" ? "% of msgs" : "Per msg";
495
+ const perMsgValue = useMemo(() => {
496
+ if (messages === 0) return "-";
497
+ return mode === "rate" ? formatRate(total, messages) : (total / messages).toFixed(0);
498
+ }, [total, messages, mode]);
499
+
500
+ return (
501
+ <div>
502
+ <div className="text-[var(--text-primary)] font-medium mb-1">{label}</div>
503
+ <div className="space-y-0.5 text-[var(--text-secondary)]">
504
+ <div className="flex items-center justify-between">
505
+ <span className="stats-text-muted text-xs">Total</span>
506
+ <span className={`font-mono text-xs ${valueClass}`}>{formatInteger(total)}</span>
507
+ </div>
508
+ <div className="flex items-center justify-between">
509
+ <span className="stats-text-muted text-xs">{perMsgLabel}</span>
510
+ <span className="font-mono text-xs stats-text-primary">{perMsgValue}</span>
511
+ </div>
512
+ </div>
513
+ </div>
514
+ );
515
+ }
516
+
517
+ const SERIES_COLORS = {
518
+ yelling: "#ed4abf", // brand pink
519
+ profanity: "#ff6b7d", // rose
520
+ anguish: "#9b4dff", // brand violet
521
+ frustration: "#5ad8e6", // brand cyan
522
+ } as const;
523
+
524
+ function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: TableChartTheme }) {
525
+ const chartData = useMemo(() => {
526
+ return {
527
+ labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
528
+ datasets: [
529
+ {
530
+ label: "CAPS",
531
+ data: data.map(d => d.yelling),
532
+ ...lineSeriesStyle(SERIES_COLORS.yelling),
533
+ },
534
+ {
535
+ label: "Profanity",
536
+ data: data.map(d => d.profanity),
537
+ ...lineSeriesStyle(SERIES_COLORS.profanity),
538
+ },
539
+ {
540
+ label: "Anguish",
541
+ data: data.map(d => d.anguish),
542
+ ...lineSeriesStyle(SERIES_COLORS.anguish),
543
+ },
544
+ {
545
+ label: "Frustration",
546
+ data: data.map(d => d.frustration),
547
+ ...lineSeriesStyle(SERIES_COLORS.frustration),
548
+ },
549
+ ],
550
+ };
551
+ }, [data]);
552
+
553
+ const options = useMemo(() => {
554
+ return {
555
+ responsive: true,
556
+ maintainAspectRatio: false,
557
+ plugins: detailChartPlugins(chartTheme),
558
+ scales: detailChartScalesSingleAxis(chartTheme),
559
+ };
560
+ }, [chartTheme]);
561
+
562
+ return <Line data={chartData} options={options} />;
563
+ }
564
+
565
+ interface DailyPoint {
566
+ timestamp: number;
567
+ yelling: number;
568
+ profanity: number;
569
+ anguish: number;
570
+ frustration: number;
571
+ total: number;
572
+ }
573
+
574
+ interface ModelTrendSeries {
575
+ data: DailyPoint[];
576
+ }
577
+
578
+ function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelTrendSeries> {
579
+ if (points.length === 0) return new Map();
580
+
581
+ const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
582
+ const byKey = new Map<string, Map<number, DailyPoint>>();
583
+
584
+ for (const point of points) {
585
+ const key = `${point.model}::${point.provider}`;
586
+ let dayMap = byKey.get(key);
587
+ if (!dayMap) {
588
+ dayMap = new Map();
589
+ byKey.set(key, dayMap);
590
+ }
591
+ const existing = dayMap.get(point.timestamp) ?? {
592
+ timestamp: point.timestamp,
593
+ yelling: 0,
594
+ profanity: 0,
595
+ anguish: 0,
596
+ frustration: 0,
597
+ total: 0,
598
+ };
599
+ existing.yelling += point.yelling;
600
+ existing.profanity += point.profanity;
601
+ existing.anguish += point.anguish;
602
+ existing.frustration += point.negation + point.repetition + point.blame;
603
+ existing.total = existing.yelling + existing.profanity + existing.anguish + existing.frustration;
604
+ dayMap.set(point.timestamp, existing);
605
+ }
606
+
607
+ const out = new Map<string, ModelTrendSeries>();
608
+ for (const [key, dayMap] of byKey) {
609
+ const data = allDays.map(
610
+ ts =>
611
+ dayMap.get(ts) ?? {
612
+ timestamp: ts,
613
+ yelling: 0,
614
+ profanity: 0,
615
+ anguish: 0,
616
+ frustration: 0,
617
+ total: 0,
618
+ },
619
+ );
620
+ out.set(key, { data });
621
+ }
622
+ return out;
623
+ }