@oh-my-pi/omp-stats 14.9.9 → 15.0.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "14.9.9",
4
+ "version": "15.0.1",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-ai": "14.9.9",
41
- "@oh-my-pi/pi-utils": "14.9.9",
40
+ "@oh-my-pi/pi-ai": "15.0.1",
41
+ "@oh-my-pi/pi-utils": "15.0.1",
42
42
  "@tailwindcss/node": "^4.2.4",
43
43
  "chart.js": "^4.5.1",
44
44
  "date-fns": "^4.1.0",
@@ -49,13 +49,13 @@
49
49
  "tailwindcss": "^4.2.4"
50
50
  },
51
51
  "devDependencies": {
52
- "@types/bun": "^1.3.13",
52
+ "@types/bun": "^1.3.14",
53
53
  "@types/react": "^19.2.14",
54
54
  "@types/react-dom": "^19.2.3",
55
55
  "postcss": "^8.5.14"
56
56
  },
57
57
  "engines": {
58
- "bun": ">=1.3.7"
58
+ "bun": ">=1.3.14"
59
59
  },
60
60
  "files": [
61
61
  "src",
@@ -11,45 +11,26 @@ import {
11
11
  Title,
12
12
  Tooltip,
13
13
  } from "chart.js";
14
- import { format } from "date-fns";
15
14
  import { useMemo, useState } from "react";
16
15
  import { Bar, Line } from "react-chartjs-2";
17
16
  import type { BehaviorTimeSeriesPoint } from "../types";
18
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";
19
31
 
20
32
  ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
21
33
 
22
- const MODEL_COLORS = [
23
- "#a78bfa", // violet
24
- "#22d3ee", // cyan
25
- "#ec4899", // pink
26
- "#4ade80", // green
27
- "#fbbf24", // amber
28
- "#f87171", // red
29
- "#60a5fa", // blue
30
- ];
31
-
32
- const CHART_THEMES = {
33
- dark: {
34
- legendLabel: "#94a3b8",
35
- tooltipBackground: "#16161e",
36
- tooltipTitle: "#f8fafc",
37
- tooltipBody: "#94a3b8",
38
- tooltipBorder: "rgba(255, 255, 255, 0.1)",
39
- grid: "rgba(255, 255, 255, 0.06)",
40
- tick: "#64748b",
41
- },
42
- light: {
43
- legendLabel: "#475569",
44
- tooltipBackground: "#ffffff",
45
- tooltipTitle: "#0f172a",
46
- tooltipBody: "#334155",
47
- tooltipBorder: "rgba(15, 23, 42, 0.18)",
48
- grid: "rgba(15, 23, 42, 0.08)",
49
- tick: "#64748b",
50
- },
51
- } as const;
52
-
53
34
  const METRIC_OPTIONS = [
54
35
  { value: "yelling", label: "Yelling" },
55
36
  { value: "profanity", label: "Profanity" },
@@ -87,98 +68,36 @@ function ratePercent(hits: number, messages: number): number {
87
68
  return (hits / messages) * 100;
88
69
  }
89
70
 
90
- interface ChartSeries {
91
- labels: string[];
92
- datasets: Array<{ label: string; data: number[] }>;
93
- }
94
-
95
71
  interface DailyBucket {
96
72
  hits: number;
97
73
  messages: number;
98
74
  }
99
75
 
100
76
  function buildAggregateSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries {
101
- if (points.length === 0) return { labels: [], datasets: [] };
102
-
103
- const byDay = new Map<number, DailyBucket>();
104
- for (const point of points) {
105
- const bucket = byDay.get(point.timestamp) ?? { hits: 0, messages: 0 };
106
- bucket.hits += pointHits(point, metric);
107
- bucket.messages += point.messages;
108
- byDay.set(point.timestamp, bucket);
109
- }
110
-
111
- const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]);
112
- return {
113
- labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")),
114
- datasets: [
115
- {
116
- label: METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits",
117
- data: sorted.map(([, b]) => ratePercent(b.hits, b.messages)),
118
- },
119
- ],
120
- };
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
+ });
121
86
  }
122
87
 
123
- function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric, topN = 5): ChartSeries {
124
- if (points.length === 0) return { labels: [], datasets: [] };
125
-
88
+ function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries {
126
89
  // Rank by message volume so the models you actually use surface first,
127
- // matching the Behavior-by-Model table.
128
- const totals = new Map<string, { model: string; provider: string; messages: number }>();
129
- for (const point of points) {
130
- const key = `${point.model}::${point.provider}`;
131
- const existing = totals.get(key);
132
- if (existing) {
133
- existing.messages += point.messages;
134
- } else {
135
- totals.set(key, { model: point.model, provider: point.provider, messages: point.messages });
136
- }
137
- }
138
-
139
- const sorted = [...totals.entries()].sort((a, b) => b[1].messages - a[1].messages);
140
- const topEntries = sorted.slice(0, topN);
141
- const topKeys = new Set(topEntries.map(([key]) => key));
142
-
143
- const modelCount = new Map<string, number>();
144
- for (const [, { model }] of topEntries) {
145
- modelCount.set(model, (modelCount.get(model) ?? 0) + 1);
146
- }
147
- const labelByKey = new Map<string, string>();
148
- for (const [key, { model, provider }] of topEntries) {
149
- labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model);
150
- }
151
-
152
- const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
153
- const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key);
154
- const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`));
155
- if (hasOther) seriesNames.push("Other");
156
-
157
- // Track hits and messages separately per (day, series), then convert to a
158
- // rate at the end. Summing rates would weight low-volume days unfairly.
159
- const dayMap = new Map<number, Record<string, DailyBucket>>();
160
- for (const day of allDays) dayMap.set(day, {});
161
- for (const point of points) {
162
- const key = `${point.model}::${point.provider}`;
163
- const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
164
- const row = dayMap.get(point.timestamp);
165
- if (!row) continue;
166
- const bucket = row[label] ?? { hits: 0, messages: 0 };
167
- bucket.hits += pointHits(point, metric);
168
- bucket.messages += point.messages;
169
- row[label] = bucket;
170
- }
171
-
172
- return {
173
- labels: allDays.map(ts => format(new Date(ts), "MMM d")),
174
- datasets: seriesNames.map(name => ({
175
- label: name,
176
- data: allDays.map(day => {
177
- const bucket = dayMap.get(day)?.[name];
178
- return bucket ? ratePercent(bucket.hits, bucket.messages) : 0;
179
- }),
180
- })),
181
- };
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
+ });
182
101
  }
183
102
 
184
103
  export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) {
@@ -192,65 +111,36 @@ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) {
192
111
  [behaviorSeries, byModel, metric],
193
112
  );
194
113
 
195
- const sharedPlugins = {
196
- legend: {
197
- display: byModel,
198
- position: "top" as const,
199
- align: "start" as const,
200
- labels: {
201
- color: chartTheme.legendLabel,
202
- usePointStyle: true,
203
- padding: 16,
204
- font: { size: 12 },
205
- boxWidth: 8,
206
- },
207
- },
208
- tooltip: {
209
- backgroundColor: chartTheme.tooltipBackground,
210
- titleColor: chartTheme.tooltipTitle,
211
- bodyColor: chartTheme.tooltipBody,
212
- borderColor: chartTheme.tooltipBorder,
213
- borderWidth: 1,
214
- padding: 12,
215
- cornerRadius: 8,
216
- callbacks: {
217
- label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
218
- const label = context.dataset.label ?? "Hits";
219
- const value = context.parsed.y ?? 0;
220
- return `${label}: ${formatRateAxis(value)}`;
221
- },
222
- },
223
- },
224
- };
114
+ const sharedPlugins = buildSharedPlugins({
115
+ chartTheme,
116
+ showLegend: byModel,
117
+ defaultLabel: "Hits",
118
+ formatValue: formatRateAxis,
119
+ });
225
120
 
226
- const sharedScaleBase = {
227
- grid: { color: chartTheme.grid, drawBorder: false },
228
- ticks: { color: chartTheme.tick, font: { size: 11 } },
229
- };
121
+ const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatRateAxis });
230
122
 
231
- const yScale = {
232
- ...sharedScaleBase,
233
- ticks: {
234
- ...sharedScaleBase.ticks,
235
- callback: (value: number | string) => formatRateAxis(Number(value)),
236
- },
237
- min: 0,
238
- };
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
+ );
239
138
 
139
+ let chartNode: React.ReactNode;
240
140
  if (byModel) {
241
141
  const lineData = {
242
142
  labels: chartData.labels,
243
- datasets: chartData.datasets.map((ds, index) => ({
244
- label: ds.label,
245
- data: ds.data,
246
- borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
247
- backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
248
- fill: true,
249
- tension: 0,
250
- pointRadius: 3,
251
- pointHoverRadius: 4,
252
- borderWidth: 2,
253
- })),
143
+ datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
254
144
  };
255
145
 
256
146
  const lineOptions: ChartOptions<"line"> = {
@@ -261,114 +151,39 @@ export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) {
261
151
  scales: { x: sharedScaleBase, y: yScale },
262
152
  };
263
153
 
264
- return (
265
- <ChartWrapper
266
- byModel={byModel}
267
- metric={metric}
268
- onByModelChange={setByModel}
269
- onMetricChange={setMetric}
270
- empty={chartData.labels.length === 0}
271
- >
272
- <Line data={lineData} options={lineOptions} />
273
- </ChartWrapper>
274
- );
275
- }
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
+ };
276
160
 
277
- const barData = {
278
- labels: chartData.labels,
279
- datasets: chartData.datasets.map((ds, index) => ({
280
- label: ds.label,
281
- data: ds.data,
282
- backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length],
283
- borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
284
- borderWidth: 0,
285
- borderRadius: 3,
286
- })),
287
- };
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
+ };
288
172
 
289
- const barOptions: ChartOptions<"bar"> = {
290
- responsive: true,
291
- maintainAspectRatio: false,
292
- interaction: { mode: "index", intersect: false },
293
- plugins: sharedPlugins,
294
- scales: {
295
- x: { ...sharedScaleBase, stacked: true },
296
- y: { ...yScale, stacked: true },
297
- },
298
- layout: { padding: { top: 8 } },
299
- };
173
+ chartNode = <Bar data={barData} options={barOptions} />;
174
+ }
300
175
 
301
176
  return (
302
- <ChartWrapper
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}
303
183
  byModel={byModel}
304
- metric={metric}
305
184
  onByModelChange={setByModel}
306
- onMetricChange={setMetric}
307
- empty={chartData.labels.length === 0}
308
185
  >
309
- <Bar data={barData} options={barOptions} />
310
- </ChartWrapper>
311
- );
312
- }
313
-
314
- interface ChartWrapperProps {
315
- byModel: boolean;
316
- metric: Metric;
317
- onByModelChange: (v: boolean) => void;
318
- onMetricChange: (v: Metric) => void;
319
- empty: boolean;
320
- children: React.ReactNode;
321
- }
322
-
323
- function ChartWrapper({ byModel, metric, onByModelChange, onMetricChange, empty, children }: ChartWrapperProps) {
324
- const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "";
325
- return (
326
- <div className="surface overflow-hidden">
327
- <div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
328
- <div>
329
- <h3 className="text-sm font-semibold text-[var(--text-primary)]">User Tantrums</h3>
330
- <p className="text-xs text-[var(--text-muted)] mt-1">{metricLabel} as % of user messages per day</p>
331
- </div>
332
- <div className="flex items-center gap-2 flex-wrap">
333
- <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
334
- {METRIC_OPTIONS.map(opt => (
335
- <button
336
- key={opt.value}
337
- type="button"
338
- onClick={() => onMetricChange(opt.value)}
339
- className={`tab-btn text-xs ${metric === opt.value ? "active" : ""}`}
340
- >
341
- {opt.label}
342
- </button>
343
- ))}
344
- </div>
345
- <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
346
- <button
347
- type="button"
348
- onClick={() => onByModelChange(false)}
349
- className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
350
- >
351
- All Models
352
- </button>
353
- <button
354
- type="button"
355
- onClick={() => onByModelChange(true)}
356
- className={`tab-btn text-xs ${byModel ? "active" : ""}`}
357
- >
358
- By Model
359
- </button>
360
- </div>
361
- </div>
362
- </div>
363
- <div className="p-5 min-h-[320px]">
364
- {empty ? (
365
- <div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
366
- No behavioral data yet. Sync to scan your sessions.
367
- </div>
368
- ) : (
369
- <div className="h-[280px]">{children}</div>
370
- )}
371
- </div>
372
- </div>
186
+ {chartNode}
187
+ </ChartFrame>
373
188
  );
374
189
  }