@gajae-code/stats 0.1.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 (67) hide show
  1. package/README.md +82 -0
  2. package/build.ts +84 -0
  3. package/dist/client/index.css +1 -0
  4. package/dist/client/index.html +13 -0
  5. package/dist/client/index.js +257 -0
  6. package/dist/client/styles.css +1159 -0
  7. package/dist/types/aggregator.d.ts +65 -0
  8. package/dist/types/client/App.d.ts +1 -0
  9. package/dist/types/client/api.d.ts +10 -0
  10. package/dist/types/client/components/BehaviorChart.d.ts +6 -0
  11. package/dist/types/client/components/BehaviorModelsTable.d.ts +7 -0
  12. package/dist/types/client/components/BehaviorSummary.d.ts +7 -0
  13. package/dist/types/client/components/ChartsContainer.d.ts +7 -0
  14. package/dist/types/client/components/CostChart.d.ts +6 -0
  15. package/dist/types/client/components/CostSummary.d.ts +6 -0
  16. package/dist/types/client/components/Header.d.ts +12 -0
  17. package/dist/types/client/components/ModelsTable.d.ts +8 -0
  18. package/dist/types/client/components/RequestDetail.d.ts +6 -0
  19. package/dist/types/client/components/RequestList.d.ts +8 -0
  20. package/dist/types/client/components/StatsGrid.d.ts +6 -0
  21. package/dist/types/client/components/chart-shared.d.ts +187 -0
  22. package/dist/types/client/components/models-table-shared.d.ts +195 -0
  23. package/dist/types/client/components/range-meta.d.ts +21 -0
  24. package/dist/types/client/index.d.ts +1 -0
  25. package/dist/types/client/types.d.ts +62 -0
  26. package/dist/types/client/useSystemTheme.d.ts +2 -0
  27. package/dist/types/db.d.ts +93 -0
  28. package/dist/types/index.d.ts +5 -0
  29. package/dist/types/parser.d.ts +40 -0
  30. package/dist/types/server.d.ts +7 -0
  31. package/dist/types/shared-types.d.ts +192 -0
  32. package/dist/types/sync-worker.d.ts +31 -0
  33. package/dist/types/types.d.ts +120 -0
  34. package/dist/types/user-metrics.d.ts +72 -0
  35. package/package.json +91 -0
  36. package/src/aggregator.ts +454 -0
  37. package/src/client/App.tsx +221 -0
  38. package/src/client/api.ts +65 -0
  39. package/src/client/components/BehaviorChart.tsx +189 -0
  40. package/src/client/components/BehaviorModelsTable.tsx +342 -0
  41. package/src/client/components/BehaviorSummary.tsx +95 -0
  42. package/src/client/components/ChartsContainer.tsx +221 -0
  43. package/src/client/components/CostChart.tsx +171 -0
  44. package/src/client/components/CostSummary.tsx +53 -0
  45. package/src/client/components/Header.tsx +72 -0
  46. package/src/client/components/ModelsTable.tsx +265 -0
  47. package/src/client/components/RequestDetail.tsx +172 -0
  48. package/src/client/components/RequestList.tsx +73 -0
  49. package/src/client/components/StatsGrid.tsx +135 -0
  50. package/src/client/components/chart-shared.tsx +320 -0
  51. package/src/client/components/models-table-shared.tsx +275 -0
  52. package/src/client/components/range-meta.ts +72 -0
  53. package/src/client/css.d.ts +1 -0
  54. package/src/client/index.tsx +6 -0
  55. package/src/client/styles.css +306 -0
  56. package/src/client/types.ts +78 -0
  57. package/src/client/useSystemTheme.ts +31 -0
  58. package/src/db.ts +1100 -0
  59. package/src/embedded-client.generated.txt +7 -0
  60. package/src/index.ts +182 -0
  61. package/src/parser.ts +334 -0
  62. package/src/server.ts +325 -0
  63. package/src/shared-types.ts +204 -0
  64. package/src/sync-worker.ts +40 -0
  65. package/src/types.ts +125 -0
  66. package/src/user-metrics.ts +686 -0
  67. package/tailwind.config.js +40 -0
@@ -0,0 +1,65 @@
1
+ import type {
2
+ BehaviorDashboardStats,
3
+ CostDashboardStats,
4
+ DashboardStats,
5
+ MessageStats,
6
+ ModelDashboardStats,
7
+ OverviewStats,
8
+ RequestDetails,
9
+ } from "./types";
10
+
11
+ const API_BASE = "/api";
12
+
13
+ export async function getStats(range = "24h"): Promise<DashboardStats> {
14
+ const res = await fetch(`${API_BASE}/stats?range=${encodeURIComponent(range)}`);
15
+ if (!res.ok) throw new Error("Failed to fetch stats");
16
+ return res.json() as Promise<DashboardStats>;
17
+ }
18
+
19
+ export async function getOverviewStats(range = "24h"): Promise<OverviewStats> {
20
+ const res = await fetch(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`);
21
+ if (!res.ok) throw new Error("Failed to fetch overview stats");
22
+ return res.json() as Promise<OverviewStats>;
23
+ }
24
+
25
+ export async function getModelDashboardStats(range = "24h"): Promise<ModelDashboardStats> {
26
+ const res = await fetch(`${API_BASE}/stats/model-dashboard?range=${encodeURIComponent(range)}`);
27
+ if (!res.ok) throw new Error("Failed to fetch model stats");
28
+ return res.json() as Promise<ModelDashboardStats>;
29
+ }
30
+
31
+ export async function getCostDashboardStats(range = "24h"): Promise<CostDashboardStats> {
32
+ const res = await fetch(`${API_BASE}/stats/costs?range=${encodeURIComponent(range)}`);
33
+ if (!res.ok) throw new Error("Failed to fetch cost stats");
34
+ return res.json() as Promise<CostDashboardStats>;
35
+ }
36
+
37
+ export async function getRecentRequests(limit = 50): Promise<MessageStats[]> {
38
+ const res = await fetch(`${API_BASE}/stats/recent?limit=${limit}`);
39
+ if (!res.ok) throw new Error("Failed to fetch recent requests");
40
+ return res.json() as Promise<MessageStats[]>;
41
+ }
42
+
43
+ export async function getRecentErrors(limit = 50): Promise<MessageStats[]> {
44
+ const res = await fetch(`${API_BASE}/stats/errors?limit=${limit}`);
45
+ if (!res.ok) throw new Error("Failed to fetch recent errors");
46
+ return res.json() as Promise<MessageStats[]>;
47
+ }
48
+
49
+ export async function getRequestDetails(id: number): Promise<RequestDetails> {
50
+ const res = await fetch(`${API_BASE}/request/${id}`);
51
+ if (!res.ok) throw new Error("Failed to fetch request details");
52
+ return res.json() as Promise<RequestDetails>;
53
+ }
54
+
55
+ export async function sync(): Promise<any> {
56
+ const res = await fetch(`${API_BASE}/sync`);
57
+ if (!res.ok) throw new Error("Failed to sync");
58
+ return res.json();
59
+ }
60
+
61
+ export async function getBehaviorDashboardStats(range = "24h"): Promise<BehaviorDashboardStats> {
62
+ const res = await fetch(`${API_BASE}/stats/behavior?range=${encodeURIComponent(range)}`);
63
+ if (!res.ok) throw new Error("Failed to fetch behavior stats");
64
+ return res.json() as Promise<BehaviorDashboardStats>;
65
+ }
@@ -0,0 +1,189 @@
1
+ import {
2
+ BarElement,
3
+ CategoryScale,
4
+ Chart as ChartJS,
5
+ type ChartOptions,
6
+ Filler,
7
+ Legend,
8
+ LinearScale,
9
+ LineElement,
10
+ PointElement,
11
+ Title,
12
+ Tooltip,
13
+ } from "chart.js";
14
+ import { useMemo, useState } from "react";
15
+ import { Bar, Line } from "react-chartjs-2";
16
+ import type { BehaviorTimeSeriesPoint } from "../types";
17
+ import { useSystemTheme } from "../useSystemTheme";
18
+ import {
19
+ barDatasetStyle,
20
+ buildAggregateTimeSeries,
21
+ buildSharedPlugins,
22
+ buildSharedScales,
23
+ buildTopNByModelSeries,
24
+ CHART_THEMES,
25
+ ChartFrame,
26
+ type ChartSeries,
27
+ lineDatasetStyle,
28
+ MODEL_COLORS,
29
+ styleDatasets,
30
+ } from "./chart-shared";
31
+
32
+ ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
33
+
34
+ const METRIC_OPTIONS = [
35
+ { value: "yelling", label: "Yelling" },
36
+ { value: "profanity", label: "Profanity" },
37
+ { value: "anguish", label: "Anguish (!!!, nooo, dude, ..)" },
38
+ { value: "negation", label: "Negation (no/nope/wrong)" },
39
+ { value: "repetition", label: "Repetition (i meant, still doesnt)" },
40
+ { value: "blame", label: "Blame (you didnt, stop X-ing)" },
41
+ { value: "frustration", label: "Frustration (neg + rep + blame)" },
42
+ { value: "total", label: "All signals combined" },
43
+ ] as const;
44
+ type Metric = (typeof METRIC_OPTIONS)[number]["value"];
45
+
46
+ function formatRateAxis(value: number): string {
47
+ if (!Number.isFinite(value)) return "-";
48
+ if (value === 0) return "0%";
49
+ if (Math.abs(value) < 1) return `${value.toFixed(1)}%`;
50
+ return `${value.toFixed(0)}%`;
51
+ }
52
+
53
+ interface BehaviorChartProps {
54
+ behaviorSeries: BehaviorTimeSeriesPoint[];
55
+ }
56
+
57
+ function pointHits(point: BehaviorTimeSeriesPoint, metric: Metric): number {
58
+ if (metric === "frustration") return point.negation + point.repetition + point.blame;
59
+ if (metric === "total") {
60
+ return point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
61
+ }
62
+ return point[metric];
63
+ }
64
+
65
+ /** Hits per 100 user messages, 0 when there were no messages. */
66
+ function ratePercent(hits: number, messages: number): number {
67
+ if (messages <= 0) return 0;
68
+ return (hits / messages) * 100;
69
+ }
70
+
71
+ interface DailyBucket {
72
+ hits: number;
73
+ messages: number;
74
+ }
75
+
76
+ function buildAggregateSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries {
77
+ const label = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits";
78
+ return buildAggregateTimeSeries<BehaviorTimeSeriesPoint, DailyBucket>(points, label, {
79
+ initBucket: () => ({ hits: 0, messages: 0 }),
80
+ accumulate: (bucket, point) => {
81
+ bucket.hits += pointHits(point, metric);
82
+ bucket.messages += point.messages;
83
+ },
84
+ bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages),
85
+ });
86
+ }
87
+
88
+ function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries {
89
+ // Rank by message volume so the models you actually use surface first,
90
+ // matching the Behavior-by-Model table. Per-bucket math tracks hits +
91
+ // messages separately so the final rate isn't skewed by low-volume days.
92
+ return buildTopNByModelSeries<BehaviorTimeSeriesPoint, DailyBucket>(points, {
93
+ rankWeight: point => point.messages,
94
+ initBucket: () => ({ hits: 0, messages: 0 }),
95
+ accumulate: (bucket, point) => {
96
+ bucket.hits += pointHits(point, metric);
97
+ bucket.messages += point.messages;
98
+ },
99
+ bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages),
100
+ });
101
+ }
102
+
103
+ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) {
104
+ const [byModel, setByModel] = useState(false);
105
+ const [metric, setMetric] = useState<Metric>("total");
106
+ const theme = useSystemTheme();
107
+ const chartTheme = CHART_THEMES[theme];
108
+
109
+ const chartData = useMemo(
110
+ () => (byModel ? buildByModelSeries(behaviorSeries, metric) : buildAggregateSeries(behaviorSeries, metric)),
111
+ [behaviorSeries, byModel, metric],
112
+ );
113
+
114
+ const sharedPlugins = buildSharedPlugins({
115
+ chartTheme,
116
+ showLegend: byModel,
117
+ defaultLabel: "Hits",
118
+ formatValue: formatRateAxis,
119
+ });
120
+
121
+ const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatRateAxis });
122
+
123
+ const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "";
124
+ const metricTabs = (
125
+ <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
126
+ {METRIC_OPTIONS.map(opt => (
127
+ <button
128
+ key={opt.value}
129
+ type="button"
130
+ onClick={() => setMetric(opt.value)}
131
+ className={`tab-btn text-xs ${metric === opt.value ? "active" : ""}`}
132
+ >
133
+ {opt.label}
134
+ </button>
135
+ ))}
136
+ </div>
137
+ );
138
+
139
+ let chartNode: React.ReactNode;
140
+ if (byModel) {
141
+ const lineData = {
142
+ labels: chartData.labels,
143
+ datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
144
+ };
145
+
146
+ const lineOptions: ChartOptions<"line"> = {
147
+ responsive: true,
148
+ maintainAspectRatio: false,
149
+ interaction: { mode: "index", intersect: false },
150
+ plugins: sharedPlugins,
151
+ scales: { x: sharedScaleBase, y: yScale },
152
+ };
153
+
154
+ chartNode = <Line data={lineData} options={lineOptions} />;
155
+ } else {
156
+ const barData = {
157
+ labels: chartData.labels,
158
+ datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
159
+ };
160
+
161
+ const barOptions: ChartOptions<"bar"> = {
162
+ responsive: true,
163
+ maintainAspectRatio: false,
164
+ interaction: { mode: "index", intersect: false },
165
+ plugins: sharedPlugins,
166
+ scales: {
167
+ x: { ...sharedScaleBase, stacked: true },
168
+ y: { ...yScale, stacked: true },
169
+ },
170
+ layout: { padding: { top: 8 } },
171
+ };
172
+
173
+ chartNode = <Bar data={barData} options={barOptions} />;
174
+ }
175
+
176
+ return (
177
+ <ChartFrame
178
+ title="User Tantrums"
179
+ subtitle={`${metricLabel} as % of user messages per day`}
180
+ empty={chartData.labels.length === 0}
181
+ emptyMessage="No behavioral data yet. Sync to scan your sessions."
182
+ controls={metricTabs}
183
+ byModel={byModel}
184
+ onByModelChange={setByModel}
185
+ >
186
+ {chartNode}
187
+ </ChartFrame>
188
+ );
189
+ }
@@ -0,0 +1,342 @@
1
+ import {
2
+ CategoryScale,
3
+ Chart as ChartJS,
4
+ Legend,
5
+ LinearScale,
6
+ LineElement,
7
+ PointElement,
8
+ Title,
9
+ Tooltip,
10
+ } from "chart.js";
11
+ import { format } from "date-fns";
12
+ import { useMemo, useState } from "react";
13
+ import { Line } from "react-chartjs-2";
14
+ import type { BehaviorModelStats, BehaviorTimeSeriesPoint } from "../types";
15
+ import { useSystemTheme } from "../useSystemTheme";
16
+ import {
17
+ DetailChartEmpty,
18
+ detailChartPlugins,
19
+ detailChartScalesSingleAxis,
20
+ ExpandableModelRow,
21
+ lineSeriesStyle,
22
+ MiniSparkline,
23
+ MODEL_COLORS,
24
+ ModelNameCell,
25
+ ModelTableBody,
26
+ ModelTableHeader,
27
+ ModelTableShell,
28
+ TABLE_CHART_THEMES,
29
+ type TableChartTheme,
30
+ TrendEmpty,
31
+ } from "./models-table-shared";
32
+
33
+ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
34
+
35
+ const SERIES_COLORS = {
36
+ yelling: "#fbbf24", // amber
37
+ profanity: "#f87171", // red
38
+ anguish: "#a78bfa", // violet
39
+ frustration: "#22d3ee", // cyan - new semantic signals
40
+ } as const;
41
+
42
+ interface BehaviorModelsTableProps {
43
+ models: BehaviorModelStats[];
44
+ behaviorSeries: BehaviorTimeSeriesPoint[];
45
+ }
46
+
47
+ interface DailyPoint {
48
+ timestamp: number;
49
+ yelling: number;
50
+ profanity: number;
51
+ anguish: number;
52
+ frustration: number;
53
+ total: number;
54
+ }
55
+
56
+ interface ModelTrendSeries {
57
+ data: DailyPoint[];
58
+ }
59
+
60
+ const GRID_TEMPLATE = "2fr 0.9fr 0.8fr 0.8fr 0.8fr 0.9fr 0.8fr 140px 40px";
61
+
62
+ function formatInt(value: number): string {
63
+ return value.toLocaleString();
64
+ }
65
+
66
+ function totalHitRate(model: BehaviorModelStats): number {
67
+ if (model.totalMessages === 0) return 0;
68
+ const hits =
69
+ model.totalYelling +
70
+ model.totalProfanity +
71
+ model.totalAnguish +
72
+ model.totalNegation +
73
+ model.totalRepetition +
74
+ model.totalBlame;
75
+ return hits / model.totalMessages;
76
+ }
77
+
78
+ /**
79
+ * Rate-as-percent. < 1% shows one decimal so a 0.4% model doesn't read as 0%.
80
+ */
81
+ function formatRate(total: number, messages: number): string {
82
+ if (messages === 0) return "-";
83
+ const pct = (total / messages) * 100;
84
+ if (pct === 0) return "0%";
85
+ if (pct < 1) return `${pct.toFixed(1)}%`;
86
+ return `${pct.toFixed(0)}%`;
87
+ }
88
+
89
+ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTableProps) {
90
+ const [expandedKey, setExpandedKey] = useState<string | null>(null);
91
+ const theme = useSystemTheme();
92
+ const chartTheme = TABLE_CHART_THEMES[theme];
93
+
94
+ const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]);
95
+
96
+ // Sort by usage so the models you actually rely on surface first; rates
97
+ // stay visible per row so a low-volume freak doesn't dominate.
98
+ const sortedModels = [...models].sort((a, b) => {
99
+ if (b.totalMessages !== a.totalMessages) return b.totalMessages - a.totalMessages;
100
+ return totalHitRate(b) - totalHitRate(a);
101
+ });
102
+
103
+ return (
104
+ <ModelTableShell
105
+ title="Behavior by Model"
106
+ subtitle="How often each model elicited a tantrum — rates are per user message"
107
+ >
108
+ <ModelTableHeader
109
+ gridTemplate={GRID_TEMPLATE}
110
+ columns={[
111
+ { label: "Model" },
112
+ { label: "Messages", align: "right" },
113
+ { label: "CAPS %", align: "right" },
114
+ { label: "Profanity %", align: "right" },
115
+ { label: "Anguish %", align: "right" },
116
+ { label: "Frustration %", align: "right" },
117
+ { label: "Hits %", align: "right" },
118
+ { label: "Trend", align: "center" },
119
+ ]}
120
+ />
121
+
122
+ <ModelTableBody>
123
+ {sortedModels.map((model, index) => {
124
+ const key = `${model.model}::${model.provider}`;
125
+ const trend = trendByKey.get(key)?.data ?? [];
126
+ const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
127
+ const isExpanded = expandedKey === key;
128
+ const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame;
129
+ const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration;
130
+
131
+ return (
132
+ <ExpandableModelRow
133
+ key={key}
134
+ gridTemplate={GRID_TEMPLATE}
135
+ isExpanded={isExpanded}
136
+ onToggle={() => setExpandedKey(isExpanded ? null : key)}
137
+ cells={[
138
+ <ModelNameCell key="name" model={model.model} provider={model.provider} />,
139
+ <div key="messages" className="text-right text-[var(--text-secondary)] font-mono text-sm">
140
+ {formatInt(model.totalMessages)}
141
+ </div>,
142
+ <div key="caps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
143
+ {formatRate(model.totalYelling, model.totalMessages)}
144
+ </div>,
145
+ <div key="profanity" className="text-right text-[var(--text-secondary)] font-mono text-sm">
146
+ {formatRate(model.totalProfanity, model.totalMessages)}
147
+ </div>,
148
+ <div key="anguish" className="text-right text-[var(--text-secondary)] font-mono text-sm">
149
+ {formatRate(model.totalAnguish, model.totalMessages)}
150
+ </div>,
151
+ <div key="frustration" className="text-right text-[var(--text-secondary)] font-mono text-sm">
152
+ {formatRate(totalFrustration, model.totalMessages)}
153
+ </div>,
154
+ <div key="hits" className="text-right text-[var(--text-secondary)] font-mono text-sm">
155
+ {formatRate(totalHits, model.totalMessages)}
156
+ </div>,
157
+ ]}
158
+ trendCell={
159
+ trend.length === 0 ? (
160
+ <TrendEmpty />
161
+ ) : (
162
+ <MiniSparkline
163
+ timestamps={trend.map(d => d.timestamp)}
164
+ values={trend.map(d => d.total)}
165
+ color={trendColor}
166
+ />
167
+ )
168
+ }
169
+ expandedContent={
170
+ <div className="grid gap-4" style={{ gridTemplateColumns: "220px 1fr" }}>
171
+ <div className="space-y-4 text-sm">
172
+ <DetailRow
173
+ label="Yelling (CAPS)"
174
+ total={model.totalYelling}
175
+ messages={model.totalMessages}
176
+ valueClass="text-[var(--accent-amber,#fbbf24)]"
177
+ />
178
+ <DetailRow
179
+ label="Profanity"
180
+ total={model.totalProfanity}
181
+ messages={model.totalMessages}
182
+ valueClass="text-[var(--accent-red,#f87171)]"
183
+ />
184
+ <DetailRow
185
+ label="Anguish (!!!, nooo, dude, ..)"
186
+ total={model.totalAnguish}
187
+ messages={model.totalMessages}
188
+ valueClass="text-[var(--accent-violet,#a78bfa)]"
189
+ />
190
+ <DetailRow
191
+ label="Negation (no/nope/wrong)"
192
+ total={model.totalNegation}
193
+ messages={model.totalMessages}
194
+ valueClass="text-[var(--accent-cyan,#22d3ee)]"
195
+ />
196
+ <DetailRow
197
+ label="Repetition (i meant, still doesnt)"
198
+ total={model.totalRepetition}
199
+ messages={model.totalMessages}
200
+ valueClass="text-[var(--accent-cyan,#22d3ee)]"
201
+ />
202
+ <DetailRow
203
+ label="Blame (you didnt, stop X-ing)"
204
+ total={model.totalBlame}
205
+ messages={model.totalMessages}
206
+ valueClass="text-[var(--accent-cyan,#22d3ee)]"
207
+ />
208
+ <DetailRow
209
+ label="Avg chars / msg"
210
+ total={model.totalChars}
211
+ messages={model.totalMessages}
212
+ valueClass="text-[var(--text-secondary)]"
213
+ mode="average"
214
+ />
215
+ </div>
216
+ <div className="h-[200px]">
217
+ {trend.length === 0 ? (
218
+ <DetailChartEmpty />
219
+ ) : (
220
+ <BreakdownChart data={trend} chartTheme={chartTheme} />
221
+ )}
222
+ </div>
223
+ </div>
224
+ }
225
+ />
226
+ );
227
+ })}
228
+ {sortedModels.length === 0 ? (
229
+ <div className="border-t border-[var(--border-subtle)] px-5 py-8 text-center text-[var(--text-muted)] text-sm">
230
+ No user behavior recorded for this range yet.
231
+ </div>
232
+ ) : null}
233
+ </ModelTableBody>
234
+ </ModelTableShell>
235
+ );
236
+ }
237
+
238
+ function DetailRow({
239
+ label,
240
+ total,
241
+ messages,
242
+ valueClass,
243
+ mode = "rate",
244
+ }: {
245
+ label: string;
246
+ total: number;
247
+ messages: number;
248
+ valueClass: string;
249
+ mode?: "rate" | "average";
250
+ }) {
251
+ const perMsgLabel = mode === "rate" ? "% of msgs" : "Per msg";
252
+ const perMsgValue =
253
+ messages > 0 ? (mode === "rate" ? formatRate(total, messages) : (total / messages).toFixed(0)) : "-";
254
+ return (
255
+ <div>
256
+ <div className="text-[var(--text-primary)] font-medium mb-2">{label}</div>
257
+ <div className="space-y-1 text-[var(--text-secondary)]">
258
+ <div className="flex items-center justify-between">
259
+ <span>Total</span>
260
+ <span className={`font-mono ${valueClass}`}>{formatInt(total)}</span>
261
+ </div>
262
+ <div className="flex items-center justify-between">
263
+ <span>{perMsgLabel}</span>
264
+ <span className="font-mono">{perMsgValue}</span>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: TableChartTheme }) {
272
+ const chartData = {
273
+ labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
274
+ datasets: [
275
+ { label: "CAPS", data: data.map(d => d.yelling), ...lineSeriesStyle(SERIES_COLORS.yelling) },
276
+ { label: "Profanity", data: data.map(d => d.profanity), ...lineSeriesStyle(SERIES_COLORS.profanity) },
277
+ { label: "Anguish", data: data.map(d => d.anguish), ...lineSeriesStyle(SERIES_COLORS.anguish) },
278
+ { label: "Frustration", data: data.map(d => d.frustration), ...lineSeriesStyle(SERIES_COLORS.frustration) },
279
+ ],
280
+ };
281
+
282
+ const options = {
283
+ responsive: true,
284
+ maintainAspectRatio: false,
285
+ plugins: detailChartPlugins(chartTheme),
286
+ scales: detailChartScalesSingleAxis(chartTheme),
287
+ };
288
+
289
+ return <Line data={chartData} options={options} />;
290
+ }
291
+
292
+ /**
293
+ * Group the daily time-series by model+provider, producing one continuous
294
+ * day-bucket array per model so the sparkline / breakdown chart can render
295
+ * without missing-day artifacts.
296
+ */
297
+ function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelTrendSeries> {
298
+ if (points.length === 0) return new Map();
299
+
300
+ const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
301
+ const byKey = new Map<string, Map<number, DailyPoint>>();
302
+
303
+ for (const point of points) {
304
+ const key = `${point.model}::${point.provider}`;
305
+ let dayMap = byKey.get(key);
306
+ if (!dayMap) {
307
+ dayMap = new Map();
308
+ byKey.set(key, dayMap);
309
+ }
310
+ const existing = dayMap.get(point.timestamp) ?? {
311
+ timestamp: point.timestamp,
312
+ yelling: 0,
313
+ profanity: 0,
314
+ anguish: 0,
315
+ frustration: 0,
316
+ total: 0,
317
+ };
318
+ existing.yelling += point.yelling;
319
+ existing.profanity += point.profanity;
320
+ existing.anguish += point.anguish;
321
+ existing.frustration += point.negation + point.repetition + point.blame;
322
+ existing.total = existing.yelling + existing.profanity + existing.anguish + existing.frustration;
323
+ dayMap.set(point.timestamp, existing);
324
+ }
325
+
326
+ const out = new Map<string, ModelTrendSeries>();
327
+ for (const [key, dayMap] of byKey) {
328
+ const data = allDays.map(
329
+ ts =>
330
+ dayMap.get(ts) ?? {
331
+ timestamp: ts,
332
+ yelling: 0,
333
+ profanity: 0,
334
+ anguish: 0,
335
+ frustration: 0,
336
+ total: 0,
337
+ },
338
+ );
339
+ out.set(key, { data });
340
+ }
341
+ return out;
342
+ }
@@ -0,0 +1,95 @@
1
+ import { useMemo } from "react";
2
+ import type { BehaviorOverallStats, BehaviorTimeSeriesPoint } from "../types";
3
+
4
+ interface BehaviorSummaryProps {
5
+ overall: BehaviorOverallStats;
6
+ behaviorSeries: BehaviorTimeSeriesPoint[];
7
+ }
8
+
9
+ function formatInt(value: number): string {
10
+ return value.toLocaleString();
11
+ }
12
+
13
+ /**
14
+ * Per-message rate for a signal. Uses 2 decimals so a 0.01-hits-per-msg model
15
+ * still distinguishes from a true zero, and never shows `NaN` or `Infinity`
16
+ * when there are no messages.
17
+ */
18
+ function perMsg(total: number, messages: number): string | undefined {
19
+ if (messages <= 0) return undefined;
20
+ return `${(total / messages).toFixed(2)} / msg`;
21
+ }
22
+
23
+ export function BehaviorSummary({ overall, behaviorSeries }: BehaviorSummaryProps) {
24
+ // Top "ranted-at" model: model that absorbed the most caps + profanity +
25
+ // anguish + frustration (negation/repetition/blame).
26
+ const topModel = useMemo(() => {
27
+ const totals = new Map<string, { model: string; provider: string; score: number }>();
28
+ for (const point of behaviorSeries) {
29
+ const key = `${point.model}::${point.provider}`;
30
+ const existing = totals.get(key);
31
+ const score =
32
+ point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
33
+ if (existing) {
34
+ existing.score += score;
35
+ } else {
36
+ totals.set(key, { model: point.model, provider: point.provider, score });
37
+ }
38
+ }
39
+ let best: { model: string; provider: string; score: number } | null = null;
40
+ for (const entry of totals.values()) {
41
+ if (!best || entry.score > best.score) best = entry;
42
+ }
43
+ return best;
44
+ }, [behaviorSeries]);
45
+
46
+ const totalFrustration = overall.totalNegation + overall.totalRepetition + overall.totalBlame;
47
+ const messages = overall.totalMessages;
48
+
49
+ const cards: Array<{ label: string; value: string; sub?: string }> = [
50
+ {
51
+ label: "Messages",
52
+ value: formatInt(overall.totalMessages),
53
+ sub: messages > 0 ? "in selected range" : undefined,
54
+ },
55
+ {
56
+ label: "Yelling",
57
+ value: formatInt(overall.totalYelling),
58
+ sub: perMsg(overall.totalYelling, messages),
59
+ },
60
+ {
61
+ label: "Profanity hits",
62
+ value: formatInt(overall.totalProfanity),
63
+ sub: perMsg(overall.totalProfanity, messages),
64
+ },
65
+ {
66
+ label: "Anguish",
67
+ value: formatInt(overall.totalAnguish),
68
+ sub: perMsg(overall.totalAnguish, messages),
69
+ },
70
+ {
71
+ label: "Frustration",
72
+ value: formatInt(totalFrustration),
73
+ sub: perMsg(totalFrustration, messages),
74
+ },
75
+ {
76
+ label: "Most yelled-at",
77
+ value: topModel?.model ?? "\u2014",
78
+ sub: topModel ? `${formatInt(topModel.score)} hits` : undefined,
79
+ },
80
+ ];
81
+
82
+ return (
83
+ <div className="grid grid-cols-2 sm:grid-cols-6 gap-4">
84
+ {cards.map(card => (
85
+ <div key={card.label} className="surface px-4 py-3">
86
+ <p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
87
+ <p className="text-lg font-semibold text-[var(--text-primary)] truncate" title={card.value}>
88
+ {card.value}
89
+ </p>
90
+ {card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
91
+ </div>
92
+ ))}
93
+ </div>
94
+ );
95
+ }