@oh-my-pi/omp-stats 14.9.3 → 14.9.7

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.
@@ -0,0 +1,465 @@
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 { ChevronDown, ChevronUp } from "lucide-react";
13
+ import { useMemo, useState } from "react";
14
+ import { Line } from "react-chartjs-2";
15
+ import type { BehaviorModelStats, BehaviorTimeSeriesPoint } from "../types";
16
+ import { useSystemTheme } from "../useSystemTheme";
17
+
18
+ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
19
+
20
+ const MODEL_COLORS = [
21
+ "#a78bfa", // violet
22
+ "#22d3ee", // cyan
23
+ "#ec4899", // pink
24
+ "#4ade80", // green
25
+ "#fbbf24", // amber
26
+ "#f87171", // red
27
+ "#60a5fa", // blue
28
+ ];
29
+
30
+ const SERIES_COLORS = {
31
+ yelling: "#fbbf24", // amber
32
+ profanity: "#f87171", // red
33
+ anguish: "#a78bfa", // violet
34
+ frustration: "#22d3ee", // cyan - new semantic signals
35
+ } as const;
36
+
37
+ const CHART_THEMES = {
38
+ dark: {
39
+ legendLabel: "#cbd5e1",
40
+ tooltipBackground: "#16161e",
41
+ tooltipTitle: "#f8fafc",
42
+ tooltipBody: "#94a3b8",
43
+ tooltipBorder: "rgba(255, 255, 255, 0.1)",
44
+ grid: "rgba(255, 255, 255, 0.06)",
45
+ tick: "#94a3b8",
46
+ },
47
+ light: {
48
+ legendLabel: "#334155",
49
+ tooltipBackground: "#ffffff",
50
+ tooltipTitle: "#0f172a",
51
+ tooltipBody: "#334155",
52
+ tooltipBorder: "rgba(15, 23, 42, 0.18)",
53
+ grid: "rgba(15, 23, 42, 0.08)",
54
+ tick: "#475569",
55
+ },
56
+ } as const;
57
+
58
+ type ChartTheme = (typeof CHART_THEMES)[keyof typeof CHART_THEMES];
59
+
60
+ interface BehaviorModelsTableProps {
61
+ models: BehaviorModelStats[];
62
+ behaviorSeries: BehaviorTimeSeriesPoint[];
63
+ }
64
+
65
+ interface DailyPoint {
66
+ timestamp: number;
67
+ yelling: number;
68
+ profanity: number;
69
+ anguish: number;
70
+ frustration: number;
71
+ total: number;
72
+ }
73
+
74
+ interface ModelTrendSeries {
75
+ data: DailyPoint[];
76
+ }
77
+
78
+ const GRID_TEMPLATE = "2fr 0.9fr 0.8fr 0.8fr 0.8fr 0.9fr 0.8fr 140px 40px";
79
+
80
+ function formatInt(value: number): string {
81
+ return value.toLocaleString();
82
+ }
83
+
84
+ function totalHitRate(model: BehaviorModelStats): number {
85
+ if (model.totalMessages === 0) return 0;
86
+ const hits =
87
+ model.totalYelling +
88
+ model.totalProfanity +
89
+ model.totalAnguish +
90
+ model.totalNegation +
91
+ model.totalRepetition +
92
+ model.totalBlame;
93
+ return hits / model.totalMessages;
94
+ }
95
+
96
+ /**
97
+ * Rate-as-percent. < 1% shows one decimal so a 0.4% model doesn't read as 0%.
98
+ */
99
+ function formatRate(total: number, messages: number): string {
100
+ if (messages === 0) return "-";
101
+ const pct = (total / messages) * 100;
102
+ if (pct === 0) return "0%";
103
+ if (pct < 1) return `${pct.toFixed(1)}%`;
104
+ return `${pct.toFixed(0)}%`;
105
+ }
106
+
107
+ export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTableProps) {
108
+ const [expandedKey, setExpandedKey] = useState<string | null>(null);
109
+ const theme = useSystemTheme();
110
+ const chartTheme = CHART_THEMES[theme];
111
+
112
+ const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]);
113
+
114
+ // Sort by usage so the models you actually rely on surface first; rates
115
+ // stay visible per row so a low-volume freak doesn't dominate.
116
+ const sortedModels = [...models].sort((a, b) => {
117
+ if (b.totalMessages !== a.totalMessages) return b.totalMessages - a.totalMessages;
118
+ return totalHitRate(b) - totalHitRate(a);
119
+ });
120
+
121
+ return (
122
+ <div className="surface overflow-hidden">
123
+ <div className="px-5 py-4 border-b border-[var(--border-subtle)]">
124
+ <h3 className="text-sm font-semibold text-[var(--text-primary)]">Behavior by Model</h3>
125
+ <p className="text-xs text-[var(--text-muted)] mt-1">
126
+ How often each model elicited a tantrum — rates are per user message
127
+ </p>
128
+ </div>
129
+
130
+ <div className="overflow-x-auto">
131
+ <div
132
+ className="grid gap-3 px-5 py-3 text-[var(--text-muted)] text-xs uppercase tracking-wider font-semibold"
133
+ style={{ gridTemplateColumns: GRID_TEMPLATE }}
134
+ >
135
+ <div>Model</div>
136
+ <div className="text-right">Messages</div>
137
+ <div className="text-right">CAPS %</div>
138
+ <div className="text-right">Profanity %</div>
139
+ <div className="text-right">Anguish %</div>
140
+ <div className="text-right">Frustration %</div>
141
+ <div className="text-right">Hits %</div>
142
+ <div className="text-center">Trend</div>
143
+ <div />
144
+ </div>
145
+
146
+ <div className="max-h-[calc(100vh-300px)] overflow-y-auto">
147
+ {sortedModels.map((model, index) => {
148
+ const key = `${model.model}::${model.provider}`;
149
+ const trend = trendByKey.get(key)?.data ?? [];
150
+ const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
151
+ const isExpanded = expandedKey === key;
152
+ const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame;
153
+ const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration;
154
+
155
+ return (
156
+ <div key={key} className="border-t border-[var(--border-subtle)]">
157
+ <button
158
+ type="button"
159
+ onClick={() => setExpandedKey(isExpanded ? null : key)}
160
+ className="w-full bg-transparent border-none text-left px-5 py-3 cursor-pointer hover:bg-[var(--bg-hover)] transition-colors"
161
+ >
162
+ <div className="grid gap-3 items-center" style={{ gridTemplateColumns: GRID_TEMPLATE }}>
163
+ <div>
164
+ <div className="font-medium text-[var(--text-primary)]">{model.model}</div>
165
+ <div className="text-xs text-[var(--text-muted)]">{model.provider}</div>
166
+ </div>
167
+ <div className="text-right text-[var(--text-secondary)] font-mono text-sm">
168
+ {formatInt(model.totalMessages)}
169
+ </div>
170
+ <div className="text-right text-[var(--text-secondary)] font-mono text-sm">
171
+ {formatRate(model.totalYelling, model.totalMessages)}
172
+ </div>
173
+ <div className="text-right text-[var(--text-secondary)] font-mono text-sm">
174
+ {formatRate(model.totalProfanity, model.totalMessages)}
175
+ </div>
176
+ <div className="text-right text-[var(--text-secondary)] font-mono text-sm">
177
+ {formatRate(model.totalAnguish, model.totalMessages)}
178
+ </div>
179
+ <div className="text-right text-[var(--text-secondary)] font-mono text-sm">
180
+ {formatRate(totalFrustration, model.totalMessages)}
181
+ </div>
182
+ <div className="text-right text-[var(--text-secondary)] font-mono text-sm">
183
+ {formatRate(totalHits, model.totalMessages)}
184
+ </div>
185
+ <div className="h-10">
186
+ {trend.length === 0 ? (
187
+ <div className="text-[var(--text-muted)] text-center text-sm">-</div>
188
+ ) : (
189
+ <TrendSparkline data={trend} color={trendColor} />
190
+ )}
191
+ </div>
192
+ <div className="flex justify-center text-[var(--text-muted)]">
193
+ {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
194
+ </div>
195
+ </div>
196
+ </button>
197
+
198
+ {isExpanded && (
199
+ <div className="px-5 py-4 bg-[var(--bg-elevated)] border-t border-[var(--border-subtle)]">
200
+ <div className="grid gap-4" style={{ gridTemplateColumns: "220px 1fr" }}>
201
+ <div className="space-y-4 text-sm">
202
+ <DetailRow
203
+ label="Yelling (CAPS)"
204
+ total={model.totalYelling}
205
+ messages={model.totalMessages}
206
+ valueClass="text-[var(--accent-amber,#fbbf24)]"
207
+ />
208
+ <DetailRow
209
+ label="Profanity"
210
+ total={model.totalProfanity}
211
+ messages={model.totalMessages}
212
+ valueClass="text-[var(--accent-red,#f87171)]"
213
+ />
214
+ <DetailRow
215
+ label="Anguish (!!!, nooo, dude, ..)"
216
+ total={model.totalAnguish}
217
+ messages={model.totalMessages}
218
+ valueClass="text-[var(--accent-violet,#a78bfa)]"
219
+ />
220
+ <DetailRow
221
+ label="Negation (no/nope/wrong)"
222
+ total={model.totalNegation}
223
+ messages={model.totalMessages}
224
+ valueClass="text-[var(--accent-cyan,#22d3ee)]"
225
+ />
226
+ <DetailRow
227
+ label="Repetition (i meant, still doesnt)"
228
+ total={model.totalRepetition}
229
+ messages={model.totalMessages}
230
+ valueClass="text-[var(--accent-cyan,#22d3ee)]"
231
+ />
232
+ <DetailRow
233
+ label="Blame (you didnt, stop X-ing)"
234
+ total={model.totalBlame}
235
+ messages={model.totalMessages}
236
+ valueClass="text-[var(--accent-cyan,#22d3ee)]"
237
+ />
238
+ <DetailRow
239
+ label="Avg chars / msg"
240
+ total={model.totalChars}
241
+ messages={model.totalMessages}
242
+ valueClass="text-[var(--text-secondary)]"
243
+ mode="average"
244
+ />
245
+ </div>
246
+ <div className="h-[200px]">
247
+ {trend.length === 0 ? (
248
+ <div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
249
+ No data available
250
+ </div>
251
+ ) : (
252
+ <BreakdownChart data={trend} chartTheme={chartTheme} />
253
+ )}
254
+ </div>
255
+ </div>
256
+ </div>
257
+ )}
258
+ </div>
259
+ );
260
+ })}
261
+ {sortedModels.length === 0 && (
262
+ <div className="border-t border-[var(--border-subtle)] px-5 py-8 text-center text-[var(--text-muted)] text-sm">
263
+ No user behavior recorded for this range yet.
264
+ </div>
265
+ )}
266
+ </div>
267
+ </div>
268
+ </div>
269
+ );
270
+ }
271
+
272
+ function DetailRow({
273
+ label,
274
+ total,
275
+ messages,
276
+ valueClass,
277
+ mode = "rate",
278
+ }: {
279
+ label: string;
280
+ total: number;
281
+ messages: number;
282
+ valueClass: string;
283
+ mode?: "rate" | "average";
284
+ }) {
285
+ const perMsgLabel = mode === "rate" ? "% of msgs" : "Per msg";
286
+ const perMsgValue =
287
+ messages > 0 ? (mode === "rate" ? formatRate(total, messages) : (total / messages).toFixed(0)) : "-";
288
+ return (
289
+ <div>
290
+ <div className="text-[var(--text-primary)] font-medium mb-2">{label}</div>
291
+ <div className="space-y-1 text-[var(--text-secondary)]">
292
+ <div className="flex items-center justify-between">
293
+ <span>Total</span>
294
+ <span className={`font-mono ${valueClass}`}>{formatInt(total)}</span>
295
+ </div>
296
+ <div className="flex items-center justify-between">
297
+ <span>{perMsgLabel}</span>
298
+ <span className="font-mono">{perMsgValue}</span>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ function TrendSparkline({ data, color }: { data: DailyPoint[]; color: string }) {
306
+ const chartData = {
307
+ labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
308
+ datasets: [
309
+ {
310
+ data: data.map(d => d.total),
311
+ borderColor: color,
312
+ backgroundColor: "transparent",
313
+ tension: 0.4,
314
+ pointRadius: 0,
315
+ borderWidth: 2,
316
+ },
317
+ ],
318
+ };
319
+
320
+ const options = {
321
+ responsive: true,
322
+ maintainAspectRatio: false,
323
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
324
+ scales: {
325
+ x: { display: false },
326
+ y: { display: false, min: 0 },
327
+ },
328
+ };
329
+
330
+ return <Line data={chartData} options={options} />;
331
+ }
332
+
333
+ function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: ChartTheme }) {
334
+ const chartData = {
335
+ labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
336
+ datasets: [
337
+ {
338
+ label: "CAPS",
339
+ data: data.map(d => d.yelling),
340
+ borderColor: SERIES_COLORS.yelling,
341
+ backgroundColor: "transparent",
342
+ tension: 0.4,
343
+ pointRadius: 0,
344
+ borderWidth: 2,
345
+ },
346
+ {
347
+ label: "Profanity",
348
+ data: data.map(d => d.profanity),
349
+ borderColor: SERIES_COLORS.profanity,
350
+ backgroundColor: "transparent",
351
+ tension: 0.4,
352
+ pointRadius: 0,
353
+ borderWidth: 2,
354
+ },
355
+ {
356
+ label: "Anguish",
357
+ data: data.map(d => d.anguish),
358
+ borderColor: SERIES_COLORS.anguish,
359
+ backgroundColor: "transparent",
360
+ tension: 0.4,
361
+ pointRadius: 0,
362
+ borderWidth: 2,
363
+ },
364
+ {
365
+ label: "Frustration",
366
+ data: data.map(d => d.frustration),
367
+ borderColor: SERIES_COLORS.frustration,
368
+ backgroundColor: "transparent",
369
+ tension: 0.4,
370
+ pointRadius: 0,
371
+ borderWidth: 2,
372
+ },
373
+ ],
374
+ };
375
+
376
+ const options = {
377
+ responsive: true,
378
+ maintainAspectRatio: false,
379
+ plugins: {
380
+ legend: {
381
+ display: true,
382
+ position: "top" as const,
383
+ labels: {
384
+ color: chartTheme.legendLabel,
385
+ usePointStyle: true,
386
+ padding: 16,
387
+ font: { size: 12 },
388
+ },
389
+ },
390
+ tooltip: {
391
+ backgroundColor: chartTheme.tooltipBackground,
392
+ titleColor: chartTheme.tooltipTitle,
393
+ bodyColor: chartTheme.tooltipBody,
394
+ borderColor: chartTheme.tooltipBorder,
395
+ borderWidth: 1,
396
+ cornerRadius: 8,
397
+ },
398
+ },
399
+ scales: {
400
+ x: {
401
+ grid: { color: chartTheme.grid },
402
+ ticks: { color: chartTheme.tick, font: { size: 11 } },
403
+ },
404
+ y: {
405
+ grid: { color: chartTheme.grid },
406
+ ticks: { color: chartTheme.tick, font: { size: 11 } },
407
+ min: 0,
408
+ },
409
+ },
410
+ };
411
+
412
+ return <Line data={chartData} options={options} />;
413
+ }
414
+
415
+ /**
416
+ * Group the daily time-series by model+provider, producing one continuous
417
+ * day-bucket array per model so the sparkline / breakdown chart can render
418
+ * without missing-day artifacts.
419
+ */
420
+ function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelTrendSeries> {
421
+ if (points.length === 0) return new Map();
422
+
423
+ const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
424
+ const byKey = new Map<string, Map<number, DailyPoint>>();
425
+
426
+ for (const point of points) {
427
+ const key = `${point.model}::${point.provider}`;
428
+ let dayMap = byKey.get(key);
429
+ if (!dayMap) {
430
+ dayMap = new Map();
431
+ byKey.set(key, dayMap);
432
+ }
433
+ const existing = dayMap.get(point.timestamp) ?? {
434
+ timestamp: point.timestamp,
435
+ yelling: 0,
436
+ profanity: 0,
437
+ anguish: 0,
438
+ frustration: 0,
439
+ total: 0,
440
+ };
441
+ existing.yelling += point.yelling;
442
+ existing.profanity += point.profanity;
443
+ existing.anguish += point.anguish;
444
+ existing.frustration += point.negation + point.repetition + point.blame;
445
+ existing.total = existing.yelling + existing.profanity + existing.anguish + existing.frustration;
446
+ dayMap.set(point.timestamp, existing);
447
+ }
448
+
449
+ const out = new Map<string, ModelTrendSeries>();
450
+ for (const [key, dayMap] of byKey) {
451
+ const data = allDays.map(
452
+ ts =>
453
+ dayMap.get(ts) ?? {
454
+ timestamp: ts,
455
+ yelling: 0,
456
+ profanity: 0,
457
+ anguish: 0,
458
+ frustration: 0,
459
+ total: 0,
460
+ },
461
+ );
462
+ out.set(key, { data });
463
+ }
464
+ return out;
465
+ }
@@ -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
+ }
@@ -53,9 +53,6 @@ const CHART_THEMES = {
53
53
  },
54
54
  } as const;
55
55
 
56
- const RANGE_OPTIONS = [14, 30, 90] as const;
57
- type RangeDays = (typeof RANGE_OPTIONS)[number];
58
-
59
56
  interface CostChartProps {
60
57
  costSeries: CostTimeSeriesPoint[];
61
58
  }
@@ -88,16 +85,12 @@ function makeBarLabelPlugin(color: string): Plugin<"bar"> {
88
85
 
89
86
  export function CostChart({ costSeries }: CostChartProps) {
90
87
  const [byModel, setByModel] = useState(false);
91
- const [days, setDays] = useState<RangeDays>(30);
92
88
  const theme = useSystemTheme();
93
89
  const chartTheme = CHART_THEMES[theme];
94
90
 
95
- const cutoff = Date.now() - days * 86400000;
96
- const filtered = useMemo(() => costSeries.filter(p => p.timestamp >= cutoff), [costSeries, cutoff]);
97
-
98
91
  const chartData = useMemo(
99
- () => (byModel ? buildByModelSeries(filtered) : buildAggregateSeries(filtered)),
100
- [filtered, byModel],
92
+ () => (byModel ? buildByModelSeries(costSeries) : buildAggregateSeries(costSeries)),
93
+ [costSeries, byModel],
101
94
  );
102
95
 
103
96
  const sharedPlugins = {
@@ -175,13 +168,7 @@ export function CostChart({ costSeries }: CostChartProps) {
175
168
  };
176
169
 
177
170
  return (
178
- <ChartWrapper
179
- byModel={byModel}
180
- days={days}
181
- onByModelChange={setByModel}
182
- onDaysChange={setDays}
183
- empty={chartData.labels.length === 0}
184
- >
171
+ <ChartWrapper byModel={byModel} onByModelChange={setByModel} empty={chartData.labels.length === 0}>
185
172
  <Line data={lineData} options={lineOptions} />
186
173
  </ChartWrapper>
187
174
  );
@@ -214,13 +201,7 @@ export function CostChart({ costSeries }: CostChartProps) {
214
201
  };
215
202
 
216
203
  return (
217
- <ChartWrapper
218
- byModel={byModel}
219
- days={days}
220
- onByModelChange={setByModel}
221
- onDaysChange={setDays}
222
- empty={chartData.labels.length === 0}
223
- >
204
+ <ChartWrapper byModel={byModel} onByModelChange={setByModel} empty={chartData.labels.length === 0}>
224
205
  <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
225
206
  </ChartWrapper>
226
207
  );
@@ -228,14 +209,12 @@ export function CostChart({ costSeries }: CostChartProps) {
228
209
 
229
210
  interface ChartWrapperProps {
230
211
  byModel: boolean;
231
- days: RangeDays;
232
212
  onByModelChange: (v: boolean) => void;
233
- onDaysChange: (v: RangeDays) => void;
234
213
  empty: boolean;
235
214
  children: React.ReactNode;
236
215
  }
237
216
 
238
- function ChartWrapper({ byModel, days, onByModelChange, onDaysChange, empty, children }: ChartWrapperProps) {
217
+ function ChartWrapper({ byModel, onByModelChange, empty, children }: ChartWrapperProps) {
239
218
  return (
240
219
  <div className="surface overflow-hidden">
241
220
  <div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
@@ -260,18 +239,6 @@ function ChartWrapper({ byModel, days, onByModelChange, onDaysChange, empty, chi
260
239
  By Model
261
240
  </button>
262
241
  </div>
263
- <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--radius-sm)] border-[var(--border-subtle)]">
264
- {RANGE_OPTIONS.map(d => (
265
- <button
266
- key={d}
267
- type="button"
268
- onClick={() => onDaysChange(d)}
269
- className={`tab-btn text-xs ${days === d ? "active" : ""}`}
270
- >
271
- {d}d
272
- </button>
273
- ))}
274
- </div>
275
242
  </div>
276
243
  </div>
277
244
  <div className="p-5 min-h-[320px]">