@oh-my-pi/omp-stats 14.9.2 → 14.9.5

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