@oh-my-pi/omp-stats 15.0.0 → 15.0.2

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.
@@ -12,45 +12,30 @@ import {
12
12
  Title,
13
13
  Tooltip,
14
14
  } from "chart.js";
15
- import { format } from "date-fns";
16
15
  import { useMemo, useState } from "react";
17
16
  import { Bar, Line } from "react-chartjs-2";
18
17
  import type { CostTimeSeriesPoint } from "../types";
19
18
  import { useSystemTheme } from "../useSystemTheme";
19
+ import {
20
+ barDatasetStyle,
21
+ buildAggregateTimeSeries,
22
+ buildSharedPlugins,
23
+ buildSharedScales,
24
+ buildTopNByModelSeries,
25
+ CHART_THEMES,
26
+ ChartFrame,
27
+ type ChartSeries,
28
+ lineDatasetStyle,
29
+ MODEL_COLORS,
30
+ styleDatasets,
31
+ } from "./chart-shared";
20
32
 
21
33
  ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
22
34
 
23
- const MODEL_COLORS = [
24
- "#a78bfa", // violet
25
- "#22d3ee", // cyan
26
- "#ec4899", // pink
27
- "#4ade80", // green
28
- "#fbbf24", // amber
29
- "#f87171", // red
30
- "#60a5fa", // blue
31
- ];
32
-
33
- const CHART_THEMES = {
34
- dark: {
35
- legendLabel: "#94a3b8",
36
- tooltipBackground: "#16161e",
37
- tooltipTitle: "#f8fafc",
38
- tooltipBody: "#94a3b8",
39
- tooltipBorder: "rgba(255, 255, 255, 0.1)",
40
- grid: "rgba(255, 255, 255, 0.06)",
41
- tick: "#64748b",
42
- barLabel: "rgba(248, 250, 252, 0.7)",
43
- },
44
- light: {
45
- legendLabel: "#475569",
46
- tooltipBackground: "#ffffff",
47
- tooltipTitle: "#0f172a",
48
- tooltipBody: "#334155",
49
- tooltipBorder: "rgba(15, 23, 42, 0.18)",
50
- grid: "rgba(15, 23, 42, 0.08)",
51
- tick: "#64748b",
52
- barLabel: "rgba(15, 23, 42, 0.6)",
53
- },
35
+ /** Cost bar labels need a per-theme color that the generic chart theme doesn't carry. */
36
+ const BAR_LABEL_COLORS = {
37
+ dark: "rgba(248, 250, 252, 0.7)",
38
+ light: "rgba(15, 23, 42, 0.6)",
54
39
  } as const;
55
40
 
56
41
  interface CostChartProps {
@@ -83,6 +68,28 @@ function makeBarLabelPlugin(color: string): Plugin<"bar"> {
83
68
  };
84
69
  }
85
70
 
71
+ function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries {
72
+ return buildAggregateTimeSeries<CostTimeSeriesPoint, { total: number }>(points, "Cost", {
73
+ initBucket: () => ({ total: 0 }),
74
+ accumulate: (bucket, point) => {
75
+ bucket.total += point.cost;
76
+ },
77
+ bucketToValue: bucket => bucket.total,
78
+ });
79
+ }
80
+
81
+ function buildByModelSeries(points: CostTimeSeriesPoint[]): ChartSeries {
82
+ // Rank models by total cost; per-day buckets are simple cost sums.
83
+ return buildTopNByModelSeries<CostTimeSeriesPoint, { total: number }>(points, {
84
+ rankWeight: point => point.cost,
85
+ initBucket: () => ({ total: 0 }),
86
+ accumulate: (bucket, point) => {
87
+ bucket.total += point.cost;
88
+ },
89
+ bucketToValue: bucket => bucket.total,
90
+ });
91
+ }
92
+
86
93
  export function CostChart({ costSeries }: CostChartProps) {
87
94
  const [byModel, setByModel] = useState(false);
88
95
  const theme = useSystemTheme();
@@ -93,70 +100,28 @@ export function CostChart({ costSeries }: CostChartProps) {
93
100
  [costSeries, byModel],
94
101
  );
95
102
 
96
- const sharedPlugins = {
97
- legend: {
98
- display: byModel,
99
- position: "top" as const,
100
- align: "start" as const,
101
- labels: {
102
- color: chartTheme.legendLabel,
103
- usePointStyle: true,
104
- padding: 16,
105
- font: { size: 12 },
106
- boxWidth: 8,
107
- },
108
- },
109
- tooltip: {
110
- backgroundColor: chartTheme.tooltipBackground,
111
- titleColor: chartTheme.tooltipTitle,
112
- bodyColor: chartTheme.tooltipBody,
113
- borderColor: chartTheme.tooltipBorder,
114
- borderWidth: 1,
115
- padding: 12,
116
- cornerRadius: 8,
117
- callbacks: {
118
- label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
119
- const label = context.dataset.label ?? "Cost";
120
- const value = context.parsed.y ?? 0;
121
- return `${label}: $${Math.round(value)}`;
122
- },
123
- footer: (items: { parsed: { y: number | null } }[]) => {
124
- if (!byModel || items.length < 2) return undefined;
125
- const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
126
- return `Total: $${Math.round(total)}`;
127
- },
128
- },
103
+ const sharedPlugins = buildSharedPlugins({
104
+ chartTheme,
105
+ showLegend: byModel,
106
+ defaultLabel: "Cost",
107
+ formatValue: v => `$${Math.round(v)}`,
108
+ footer: items => {
109
+ if (!byModel || items.length < 2) return undefined;
110
+ const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
111
+ return `Total: $${Math.round(total)}`;
129
112
  },
130
- };
113
+ });
131
114
 
132
- const sharedScaleBase = {
133
- grid: { color: chartTheme.grid, drawBorder: false },
134
- ticks: { color: chartTheme.tick, font: { size: 11 } },
135
- };
136
-
137
- const yScale = {
138
- ...sharedScaleBase,
139
- ticks: {
140
- ...sharedScaleBase.ticks,
141
- callback: (value: number | string) => `$${Math.round(Number(value))}`,
142
- },
143
- min: 0,
144
- };
115
+ const { sharedScaleBase, yScale } = buildSharedScales({
116
+ chartTheme,
117
+ formatY: v => `$${Math.round(v)}`,
118
+ });
145
119
 
120
+ let chartNode: React.ReactNode;
146
121
  if (byModel) {
147
122
  const lineData = {
148
123
  labels: chartData.labels,
149
- datasets: chartData.datasets.map((ds, index) => ({
150
- label: ds.label,
151
- data: ds.data,
152
- borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
153
- backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
154
- fill: true,
155
- tension: 0,
156
- pointRadius: 3,
157
- pointHoverRadius: 4,
158
- borderWidth: 2,
159
- })),
124
+ datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
160
125
  };
161
126
 
162
127
  const lineOptions: ChartOptions<"line"> = {
@@ -167,166 +132,40 @@ export function CostChart({ costSeries }: CostChartProps) {
167
132
  scales: { x: sharedScaleBase, y: yScale },
168
133
  };
169
134
 
170
- return (
171
- <ChartWrapper byModel={byModel} onByModelChange={setByModel} empty={chartData.labels.length === 0}>
172
- <Line data={lineData} options={lineOptions} />
173
- </ChartWrapper>
174
- );
175
- }
176
-
177
- const barData = {
178
- labels: chartData.labels,
179
- datasets: chartData.datasets.map((ds, index) => ({
180
- label: ds.label,
181
- data: ds.data,
182
- backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length],
183
- borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
184
- borderWidth: 0,
185
- borderRadius: 3,
186
- })),
187
- };
135
+ chartNode = <Line data={lineData} options={lineOptions} />;
136
+ } else {
137
+ const barData = {
138
+ labels: chartData.labels,
139
+ datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
140
+ };
188
141
 
189
- const barLabelPlugin = makeBarLabelPlugin(chartTheme.barLabel);
142
+ const barLabelPlugin = makeBarLabelPlugin(BAR_LABEL_COLORS[theme]);
190
143
 
191
- const barOptions: ChartOptions<"bar"> = {
192
- responsive: true,
193
- maintainAspectRatio: false,
194
- interaction: { mode: "index", intersect: false },
195
- plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"],
196
- scales: {
197
- x: { ...sharedScaleBase, stacked: true },
198
- y: { ...yScale, stacked: true },
199
- },
200
- layout: { padding: { top: 24 } },
201
- };
202
-
203
- return (
204
- <ChartWrapper byModel={byModel} onByModelChange={setByModel} empty={chartData.labels.length === 0}>
205
- <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
206
- </ChartWrapper>
207
- );
208
- }
144
+ const barOptions: ChartOptions<"bar"> = {
145
+ responsive: true,
146
+ maintainAspectRatio: false,
147
+ interaction: { mode: "index", intersect: false },
148
+ plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"],
149
+ scales: {
150
+ x: { ...sharedScaleBase, stacked: true },
151
+ y: { ...yScale, stacked: true },
152
+ },
153
+ layout: { padding: { top: 24 } },
154
+ };
209
155
 
210
- interface ChartWrapperProps {
211
- byModel: boolean;
212
- onByModelChange: (v: boolean) => void;
213
- empty: boolean;
214
- children: React.ReactNode;
215
- }
156
+ chartNode = <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />;
157
+ }
216
158
 
217
- function ChartWrapper({ byModel, onByModelChange, empty, children }: ChartWrapperProps) {
218
159
  return (
219
- <div className="surface overflow-hidden">
220
- <div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
221
- <div>
222
- <h3 className="text-sm font-semibold text-[var(--text-primary)]">Daily Cost</h3>
223
- <p className="text-xs text-[var(--text-muted)] mt-1">API spending over time</p>
224
- </div>
225
- <div className="flex items-center gap-2">
226
- <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
227
- <button
228
- type="button"
229
- onClick={() => onByModelChange(false)}
230
- className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
231
- >
232
- All Models
233
- </button>
234
- <button
235
- type="button"
236
- onClick={() => onByModelChange(true)}
237
- className={`tab-btn text-xs ${byModel ? "active" : ""}`}
238
- >
239
- By Model
240
- </button>
241
- </div>
242
- </div>
243
- </div>
244
- <div className="p-5 min-h-[320px]">
245
- {empty ? (
246
- <div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
247
- No cost data available
248
- </div>
249
- ) : (
250
- <div className="h-[280px]">{children}</div>
251
- )}
252
- </div>
253
- </div>
160
+ <ChartFrame
161
+ title="Daily Cost"
162
+ subtitle="API spending over time"
163
+ empty={chartData.labels.length === 0}
164
+ emptyMessage="No cost data available"
165
+ byModel={byModel}
166
+ onByModelChange={setByModel}
167
+ >
168
+ {chartNode}
169
+ </ChartFrame>
254
170
  );
255
171
  }
256
-
257
- interface ChartSeries {
258
- labels: string[];
259
- datasets: Array<{ label: string; data: number[] }>;
260
- }
261
-
262
- function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries {
263
- if (points.length === 0) return { labels: [], datasets: [] };
264
-
265
- const byDay = new Map<number, number>();
266
- for (const point of points) {
267
- byDay.set(point.timestamp, (byDay.get(point.timestamp) ?? 0) + point.cost);
268
- }
269
-
270
- const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]);
271
- return {
272
- labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")),
273
- datasets: [{ label: "Cost", data: sorted.map(([, cost]) => cost) }],
274
- };
275
- }
276
-
277
- function buildByModelSeries(points: CostTimeSeriesPoint[], topN = 5): ChartSeries {
278
- if (points.length === 0) return { labels: [], datasets: [] };
279
-
280
- // Rank models by total cost
281
- const totals = new Map<string, { model: string; provider: string; total: number }>();
282
- for (const point of points) {
283
- const key = `${point.model}::${point.provider}`;
284
- const existing = totals.get(key);
285
- if (existing) {
286
- existing.total += point.cost;
287
- } else {
288
- totals.set(key, { model: point.model, provider: point.provider, total: point.cost });
289
- }
290
- }
291
-
292
- const sorted = [...totals.entries()].sort((a, b) => b[1].total - a[1].total);
293
- const topEntries = sorted.slice(0, topN);
294
- const topKeys = new Set(topEntries.map(([key]) => key));
295
-
296
- // Disambiguate model labels when same model name appears from multiple providers
297
- const modelCount = new Map<string, number>();
298
- for (const [, { model }] of topEntries) {
299
- modelCount.set(model, (modelCount.get(model) ?? 0) + 1);
300
- }
301
- const labelByKey = new Map<string, string>();
302
- for (const [key, { model, provider }] of topEntries) {
303
- labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model);
304
- }
305
-
306
- // Collect all day buckets
307
- const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
308
-
309
- // Build per-day, per-series totals
310
- const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key);
311
- const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`));
312
- if (hasOther) seriesNames.push("Other");
313
-
314
- const dayMap = new Map<number, Record<string, number>>();
315
- for (const day of allDays) {
316
- dayMap.set(day, {});
317
- }
318
- for (const point of points) {
319
- const key = `${point.model}::${point.provider}`;
320
- const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
321
- const row = dayMap.get(point.timestamp)!;
322
- row[label] = (row[label] ?? 0) + point.cost;
323
- }
324
-
325
- return {
326
- labels: allDays.map(ts => format(new Date(ts), "MMM d")),
327
- datasets: seriesNames.map(name => ({
328
- label: name,
329
- data: allDays.map(day => dayMap.get(day)?.[name] ?? 0),
330
- })),
331
- };
332
- }