ai-wrapped 0.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.
Files changed (64) hide show
  1. package/.0spec/config.toml +50 -0
  2. package/.0spec/flows/ai-stats-build.toml +291 -0
  3. package/.0spec/flows/ai-stats-fix.toml +285 -0
  4. package/.0spec/flows/ai-stats.toml +400 -0
  5. package/.github/workflows/publish.yml +28 -0
  6. package/CLAUDE.md +111 -0
  7. package/README.md +64 -0
  8. package/bun.lock +635 -0
  9. package/electrobun.config.ts +25 -0
  10. package/package.json +36 -0
  11. package/public/tray-icon.png +0 -0
  12. package/src/bun/aggregator.test.ts +49 -0
  13. package/src/bun/aggregator.ts +130 -0
  14. package/src/bun/discovery/claude.ts +18 -0
  15. package/src/bun/discovery/codex.ts +20 -0
  16. package/src/bun/discovery/copilot.ts +13 -0
  17. package/src/bun/discovery/droid.ts +13 -0
  18. package/src/bun/discovery/gemini.ts +13 -0
  19. package/src/bun/discovery/index.ts +28 -0
  20. package/src/bun/discovery/opencode.ts +13 -0
  21. package/src/bun/discovery/types.ts +13 -0
  22. package/src/bun/discovery/utils.ts +48 -0
  23. package/src/bun/index.ts +722 -0
  24. package/src/bun/normalizer.test.ts +101 -0
  25. package/src/bun/normalizer.ts +454 -0
  26. package/src/bun/parsers/claude.ts +234 -0
  27. package/src/bun/parsers/codex.test.ts +180 -0
  28. package/src/bun/parsers/codex.ts +435 -0
  29. package/src/bun/parsers/copilot.ts +4 -0
  30. package/src/bun/parsers/droid.ts +4 -0
  31. package/src/bun/parsers/gemini.ts +4 -0
  32. package/src/bun/parsers/generic.test.ts +97 -0
  33. package/src/bun/parsers/generic.ts +260 -0
  34. package/src/bun/parsers/index.ts +37 -0
  35. package/src/bun/parsers/opencode.ts +4 -0
  36. package/src/bun/parsers/types.ts +23 -0
  37. package/src/bun/pricing.ts +52 -0
  38. package/src/bun/scan.ts +77 -0
  39. package/src/bun/session-schema.ts +1 -0
  40. package/src/bun/store.ts +283 -0
  41. package/src/mainview/App.tsx +42 -0
  42. package/src/mainview/components/AgentBadge.tsx +17 -0
  43. package/src/mainview/components/Dashboard.tsx +229 -0
  44. package/src/mainview/components/DashboardCharts.tsx +499 -0
  45. package/src/mainview/components/EmptyState.tsx +17 -0
  46. package/src/mainview/components/Sidebar.tsx +30 -0
  47. package/src/mainview/components/StatsCards.tsx +118 -0
  48. package/src/mainview/hooks/useDashboardData.ts +315 -0
  49. package/src/mainview/hooks/useRPC.ts +29 -0
  50. package/src/mainview/index.css +195 -0
  51. package/src/mainview/index.html +12 -0
  52. package/src/mainview/index.ts +12 -0
  53. package/src/mainview/lib/constants.ts +32 -0
  54. package/src/mainview/lib/formatters.ts +82 -0
  55. package/src/shared/constants.ts +1 -0
  56. package/src/shared/schema.ts +71 -0
  57. package/src/shared/session-types.ts +61 -0
  58. package/src/shared/types.ts +59 -0
  59. package/src/types/electrobun-bun.d.ts +117 -0
  60. package/src/types/electrobun-root.d.ts +3 -0
  61. package/src/types/electrobun-view.d.ts +38 -0
  62. package/tsconfig.json +18 -0
  63. package/tsconfig.typecheck.json +11 -0
  64. package/vite.config.ts +23 -0
@@ -0,0 +1,499 @@
1
+ import type { ReactNode } from "react";
2
+ import { SESSION_SOURCES, type SessionSource } from "@shared/schema";
3
+ import {
4
+ Area,
5
+ AreaChart,
6
+ Bar,
7
+ BarChart,
8
+ CartesianGrid,
9
+ Cell,
10
+ Pie,
11
+ PieChart,
12
+ ResponsiveContainer,
13
+ Tooltip,
14
+ XAxis,
15
+ YAxis,
16
+ } from "recharts";
17
+ import { formatDate, formatNumber, formatTokens, formatUsd } from "../lib/formatters";
18
+ import { SOURCE_COLORS, SOURCE_LABELS } from "../lib/constants";
19
+ import type {
20
+ AgentBreakdown,
21
+ DailyAgentTokensByDate,
22
+ ModelBreakdown,
23
+ TimelinePoint,
24
+ } from "../hooks/useDashboardData";
25
+
26
+ interface DashboardChartsProps {
27
+ dateFrom: string;
28
+ dateTo: string;
29
+ modelBreakdown: ModelBreakdown[];
30
+ agentBreakdown: AgentBreakdown[];
31
+ timeline: TimelinePoint[];
32
+ dailyAgentTokensByDate: DailyAgentTokensByDate;
33
+ topRepos: Array<{ repo: string; sessions: number; costUsd: number }>;
34
+ totalCostUsd: number;
35
+ dailyAverageCostUsd: number;
36
+ mostExpensiveDay: TimelinePoint | null;
37
+ }
38
+
39
+ interface HeatmapCell {
40
+ date: string;
41
+ sessions: number;
42
+ tokens: number;
43
+ costUsd: number;
44
+ intensity: number;
45
+ }
46
+
47
+ type HeatmapWeek = Array<HeatmapCell | null>;
48
+
49
+ const MODEL_COLORS = ["#22d3ee", "#3b82f6", "#14b8a6", "#0ea5e9", "#06b6d4", "#38bdf8", "#34d399", "#67e8f9"];
50
+
51
+ const AgentIconSvg = ({ d }: { d: string }) => (
52
+ <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" className="inline-block align-[-0.125em]">
53
+ <path fill="currentColor" d={d} />
54
+ </svg>
55
+ );
56
+
57
+ const AGENT_ICONS: Record<string, ReactNode> = {
58
+ claude: <AgentIconSvg d="m4.714 15.956l4.718-2.648l.079-.23l-.08-.128h-.23l-.79-.048l-2.695-.073l-2.337-.097l-2.265-.122l-.57-.121l-.535-.704l.055-.353l.48-.321l.685.06l1.518.104l2.277.157l1.651.098l2.447.255h.389l.054-.158l-.133-.097l-.103-.098l-2.356-1.596l-2.55-1.688l-1.336-.972l-.722-.491L2 6.223l-.158-1.008l.656-.722l.88.06l.224.061l.893.686l1.906 1.476l2.49 1.833l.364.304l.146-.104l.018-.072l-.164-.274l-1.354-2.446l-1.445-2.49l-.644-1.032l-.17-.619a3 3 0 0 1-.103-.729L6.287.133L6.7 0l.995.134l.42.364l.619 1.415L9.735 4.14l1.555 3.03l.455.898l.243.832l.09.255h.159V9.01l.127-1.706l.237-2.095l.23-2.695l.08-.76l.376-.91l.747-.492l.583.28l.48.685l-.067.444l-.286 1.851l-.558 2.903l-.365 1.942h.213l.243-.242l.983-1.306l1.652-2.064l.728-.82l.85-.904l.547-.431h1.032l.759 1.129l-.34 1.166l-1.063 1.347l-.88 1.142l-1.263 1.7l-.79 1.36l.074.11l.188-.02l2.853-.606l1.542-.28l1.84-.315l.832.388l.09.395l-.327.807l-1.967.486l-2.307.462l-3.436.813l-.043.03l.049.061l1.548.146l.662.036h1.62l3.018.225l.79.522l.473.638l-.08.485l-1.213.62l-1.64-.389l-3.825-.91l-1.31-.329h-.183v.11l1.093 1.068l2.003 1.81l2.508 2.33l.127.578l-.321.455l-.34-.049l-2.204-1.657l-.85-.747l-1.925-1.62h-.127v.17l.443.649l2.343 3.521l.122 1.08l-.17.353l-.607.213l-.668-.122l-1.372-1.924l-1.415-2.168l-1.141-1.943l-.14.08l-.674 7.254l-.316.37l-.728.28l-.607-.461l-.322-.747l.322-1.476l.388-1.924l.316-1.53l.285-1.9l.17-.632l-.012-.042l-.14.018l-1.432 1.967l-2.18 2.945l-1.724 1.845l-.413.164l-.716-.37l.066-.662l.401-.589l2.386-3.036l1.439-1.882l.929-1.086l-.006-.158h-.055L4.138 18.56l-1.13.146l-.485-.456l.06-.746l.231-.243l1.907-1.312Z" />,
59
+ codex: <AgentIconSvg d="M20.562 10.188c.25-.688.313-1.376.25-2.063c-.062-.687-.312-1.375-.625-2c-.562-.937-1.375-1.687-2.312-2.125c-1-.437-2.063-.562-3.125-.312c-.5-.5-1.063-.938-1.688-1.25S11.687 2 11 2a5.17 5.17 0 0 0-3 .938c-.875.624-1.5 1.5-1.813 2.5c-.75.187-1.375.5-2 .875c-.562.437-1 1-1.375 1.562c-.562.938-.75 2-.625 3.063a5.44 5.44 0 0 0 1.25 2.874a4.7 4.7 0 0 0-.25 2.063c.063.688.313 1.375.625 2c.563.938 1.375 1.688 2.313 2.125c1 .438 2.062.563 3.125.313c.5.5 1.062.937 1.687 1.25S12.312 22 13 22a5.17 5.17 0 0 0 3-.937c.875-.625 1.5-1.5 1.812-2.5a4.54 4.54 0 0 0 1.938-.875c.562-.438 1.062-.938 1.375-1.563c.562-.937.75-2 .625-3.062c-.125-1.063-.5-2.063-1.188-2.876m-7.5 10.5c-1 0-1.75-.313-2.437-.875c0 0 .062-.063.125-.063l4-2.312a.5.5 0 0 0 .25-.25a.57.57 0 0 0 .062-.313V11.25l1.688 1v4.625a3.685 3.685 0 0 1-3.688 3.813M5 17.25c-.438-.75-.625-1.625-.438-2.5c0 0 .063.063.125.063l4 2.312a.56.56 0 0 0 .313.063c.125 0 .25 0 .312-.063l4.875-2.812v1.937l-4.062 2.375A3.7 3.7 0 0 1 7.312 19c-1-.25-1.812-.875-2.312-1.75M3.937 8.563a3.8 3.8 0 0 1 1.938-1.626v4.751c0 .124 0 .25.062.312a.5.5 0 0 0 .25.25l4.875 2.813l-1.687 1l-4-2.313a3.7 3.7 0 0 1-1.75-2.25c-.25-.937-.188-2.062.312-2.937M17.75 11.75l-4.875-2.812l1.687-1l4 2.312c.625.375 1.125.875 1.438 1.5s.5 1.313.437 2.063a3.7 3.7 0 0 1-.75 1.937c-.437.563-1 1-1.687 1.25v-4.75c0-.125 0-.25-.063-.312c0 0-.062-.126-.187-.188m1.687-2.5s-.062-.062-.125-.062l-4-2.313c-.125-.062-.187-.062-.312-.062s-.25 0-.313.062L9.812 9.688V7.75l4.063-2.375c.625-.375 1.312-.5 2.062-.5c.688 0 1.375.25 2 .688c.563.437 1.063 1 1.313 1.625s.312 1.375.187 2.062m-10.5 3.5l-1.687-1V7.063c0-.688.187-1.438.562-2C8.187 4.438 8.75 4 9.375 3.688a3.37 3.37 0 0 1 2.062-.313c.688.063 1.375.375 1.938.813c0 0-.063.062-.125.062l-4 2.313a.5.5 0 0 0-.25.25c-.063.125-.063.187-.063.312zm.875-2L12 9.5l2.187 1.25v2.5L12 14.5l-2.188-1.25z" />,
60
+ gemini: <AgentIconSvg d="M24 12.024c-6.437.388-11.59 5.539-11.977 11.976h-.047C11.588 17.563 6.436 12.412 0 12.024v-.047C6.437 11.588 11.588 6.437 11.976 0h.047c.388 6.437 5.54 11.588 11.977 11.977z" />,
61
+ opencode: "💻",
62
+ droid: "🤖",
63
+ copilot: "🛸",
64
+ };
65
+
66
+ const formatShortDate = (value: string): string => {
67
+ const parsed = Date.parse(`${value}T00:00:00Z`);
68
+ if (Number.isNaN(parsed)) return value;
69
+ return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(new Date(parsed));
70
+ };
71
+
72
+ const buildHeatmap = (timeline: TimelinePoint[], dateFrom: string, dateTo: string): HeatmapCell[] => {
73
+ const byDate = new Map<string, TimelinePoint>();
74
+ for (const point of timeline) byDate.set(point.date, point);
75
+
76
+ const start = new Date(`${dateFrom}T00:00:00Z`);
77
+ const end = new Date(`${dateTo}T00:00:00Z`);
78
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) return [];
79
+
80
+ const maxTokens = timeline.reduce((max, point) => Math.max(max, point.tokens), 0);
81
+ const cells: HeatmapCell[] = [];
82
+ const cursor = new Date(start);
83
+
84
+ while (cursor <= end) {
85
+ const iso = cursor.toISOString().slice(0, 10);
86
+ const point = byDate.get(iso);
87
+ const tokens = point?.tokens ?? 0;
88
+ cells.push({
89
+ date: iso,
90
+ sessions: point?.sessions ?? 0,
91
+ tokens,
92
+ costUsd: point?.costUsd ?? 0,
93
+ intensity: maxTokens > 0 ? tokens / maxTokens : 0,
94
+ });
95
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
96
+ }
97
+
98
+ return cells;
99
+ };
100
+
101
+ const buildHeatmapWeeks = (cells: HeatmapCell[]): HeatmapWeek[] => {
102
+ const weeks: HeatmapWeek[] = [];
103
+
104
+ for (const cell of cells) {
105
+ const parsed = Date.parse(`${cell.date}T00:00:00Z`);
106
+ if (Number.isNaN(parsed)) continue;
107
+ const dayIndex = new Date(parsed).getUTCDay();
108
+
109
+ if (weeks.length === 0 || dayIndex === 0) {
110
+ weeks.push(Array<HeatmapCell | null>(7).fill(null));
111
+ }
112
+
113
+ weeks[weeks.length - 1][dayIndex] = cell;
114
+ }
115
+
116
+ return weeks;
117
+ };
118
+
119
+ const chartWrapperClass = "h-56 w-full sm:h-64";
120
+
121
+ const formatTokensTooltipWithValue = (value: number | string | undefined) => {
122
+ const numericValue = typeof value === "number" ? value : Number(value ?? 0);
123
+ return [`${formatTokens(numericValue)} (${formatNumber(numericValue)})`, "Tokens"];
124
+ };
125
+
126
+ const formatUsdTooltip = (value: number | string | undefined) =>
127
+ formatUsd(typeof value === "number" ? value : Number(value ?? 0));
128
+
129
+ const formatNumberTooltip = (value: number | string | undefined) =>
130
+ formatNumber(typeof value === "number" ? value : Number(value ?? 0));
131
+
132
+ const buildActivityTooltip = (cell: HeatmapCell, dailyAgentTokensByDate: DailyAgentTokensByDate): string => {
133
+ const agentTotals = dailyAgentTokensByDate[cell.date];
134
+
135
+ const lines = SESSION_SOURCES
136
+ .map((source) => ({
137
+ source,
138
+ tokens: agentTotals?.[source] ?? 0,
139
+ }))
140
+ .filter((entry) => entry.tokens > 0)
141
+ .sort((left, right) => right.tokens - left.tokens)
142
+ .map(
143
+ (entry) =>
144
+ `- ${SOURCE_LABELS[entry.source as SessionSource]} - ${formatTokens(entry.tokens)}`,
145
+ );
146
+
147
+ if (lines.length === 0) {
148
+ lines.push(`- All agents - ${formatTokens(cell.tokens)}`);
149
+ }
150
+
151
+ return `${formatDate(cell.date)}\n${lines.join("\n")}`;
152
+ };
153
+
154
+ const DashboardCharts = ({
155
+ dateFrom,
156
+ dateTo,
157
+ modelBreakdown,
158
+ agentBreakdown,
159
+ timeline,
160
+ dailyAgentTokensByDate,
161
+ topRepos,
162
+ totalCostUsd,
163
+ dailyAverageCostUsd,
164
+ mostExpensiveDay,
165
+ }: DashboardChartsProps) => {
166
+ const totalModelTokens = modelBreakdown.reduce((sum, row) => sum + row.tokens, 0);
167
+ const modelRows = modelBreakdown.map((row, index) => ({
168
+ ...row,
169
+ color: MODEL_COLORS[index % MODEL_COLORS.length],
170
+ percentage: totalModelTokens > 0 ? (row.tokens / totalModelTokens) * 100 : 0,
171
+ }));
172
+ const chartModelRows = modelRows.slice(0, 8);
173
+
174
+ const totalAgentTokens = agentBreakdown.reduce((sum, row) => sum + row.tokens, 0);
175
+ const agentRows = (() => {
176
+ const mapped = agentBreakdown.map((row) => ({
177
+ source: row.source,
178
+ label: row.label,
179
+ sessions: row.sessions,
180
+ tokens: row.tokens,
181
+ costUsd: row.costUsd,
182
+ color: SOURCE_COLORS[row.source] ?? "#94a3b8",
183
+ percentage: totalAgentTokens > 0 ? (row.tokens / totalAgentTokens) * 100 : 0,
184
+ icon: AGENT_ICONS[row.source] ?? ("🤝" as ReactNode),
185
+ }));
186
+ const major = mapped.filter((r) => r.percentage >= 1);
187
+ const minor = mapped.filter((r) => r.percentage < 1 && r.percentage > 0);
188
+ if (minor.length === 0) return major;
189
+ const otherTokens = minor.reduce((s, r) => s + r.tokens, 0);
190
+ const otherSessions = minor.reduce((s, r) => s + r.sessions, 0);
191
+ const otherCost = minor.reduce((s, r) => s + r.costUsd, 0);
192
+ return [
193
+ ...major,
194
+ {
195
+ source: "other" as AgentBreakdown["source"],
196
+ label: "Others",
197
+ sessions: otherSessions,
198
+ tokens: otherTokens,
199
+ costUsd: otherCost,
200
+ color: "#94a3b8",
201
+ percentage: totalAgentTokens > 0 ? (otherTokens / totalAgentTokens) * 100 : 0,
202
+ icon: "🤝" as ReactNode,
203
+ },
204
+ ];
205
+ })();
206
+
207
+ const heatmap = buildHeatmap(timeline, dateFrom, dateTo);
208
+ const heatmapWeeks = buildHeatmapWeeks(heatmap);
209
+
210
+ return (
211
+ <>
212
+ <section className="wrapped-card wrapped-card-models">
213
+ <header className="mb-6 flex flex-wrap items-end justify-between gap-3">
214
+ <div>
215
+ <p className="wrapped-kicker">Card 3</p>
216
+ <h2 className="wrapped-title">Your Top Models</h2>
217
+ </div>
218
+ <p className="text-sm text-slate-300">Ranked by token usage</p>
219
+ </header>
220
+
221
+ {modelRows.length === 0 ? (
222
+ <p className="text-sm text-slate-300">No model activity found in this range.</p>
223
+ ) : (
224
+ <div className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
225
+ <div className={chartWrapperClass}>
226
+ <ResponsiveContainer width="100%" height="100%">
227
+ <BarChart data={chartModelRows} layout="vertical" margin={{ left: 18, right: 16 }}>
228
+ <CartesianGrid stroke="rgba(148,163,184,0.22)" strokeDasharray="2 5" />
229
+ <XAxis type="number" tick={{ fill: "#cbd5e1", fontSize: 11 }} tickLine={false} axisLine={false} />
230
+ <YAxis
231
+ dataKey="model"
232
+ type="category"
233
+ tick={{ fill: "#e2e8f0", fontSize: 12 }}
234
+ tickLine={false}
235
+ axisLine={false}
236
+ width={188}
237
+ />
238
+ <Tooltip
239
+ cursor={{ fill: "rgba(59,130,246,0.15)" }}
240
+ contentStyle={{
241
+ background: "rgba(2,6,23,0.95)",
242
+ border: "1px solid rgba(148,163,184,0.35)",
243
+ borderRadius: "12px",
244
+ }}
245
+ formatter={formatTokensTooltipWithValue}
246
+ />
247
+ <Bar dataKey="tokens" name="Tokens" radius={[0, 10, 10, 0]}>
248
+ {chartModelRows.map((row) => (
249
+ <Cell key={row.model} fill={row.color} />
250
+ ))}
251
+ </Bar>
252
+ </BarChart>
253
+ </ResponsiveContainer>
254
+ </div>
255
+
256
+ <div className="space-y-2 max-h-[28rem] overflow-y-auto pr-1">
257
+ {modelRows.map((row) => (
258
+ <article
259
+ key={row.model}
260
+ className="wrapped-tile"
261
+ title={`${row.model}: ${formatNumber(row.tokens)} tokens (${row.percentage.toFixed(1)}%)`}
262
+ >
263
+ <div className="flex items-center justify-between text-sm text-slate-200">
264
+ <span className="truncate pr-3">{row.model}</span>
265
+ <span>{row.percentage.toFixed(1)}%</span>
266
+ </div>
267
+ <div className="mt-2 h-2 rounded-full bg-slate-700/45">
268
+ <div
269
+ className="h-full rounded-full transition-all duration-700"
270
+ style={{ width: `${row.percentage}%`, backgroundColor: row.color }}
271
+ />
272
+ </div>
273
+ <p className="mt-2 text-xs text-slate-300">{formatTokens(row.tokens)} ({formatNumber(row.tokens)})</p>
274
+ </article>
275
+ ))}
276
+ </div>
277
+ </div>
278
+ )}
279
+ </section>
280
+
281
+ <section className="wrapped-card wrapped-card-agents">
282
+ <header className="mb-6 flex flex-wrap items-end justify-between gap-3">
283
+ <div>
284
+ <p className="wrapped-kicker">Card 4</p>
285
+ <h2 className="wrapped-title">Your Agents</h2>
286
+ </div>
287
+ <p className="text-sm text-slate-300">Token distribution by agent</p>
288
+ </header>
289
+
290
+ {agentRows.length === 0 ? (
291
+ <p className="text-sm text-slate-300">No agent data found for this period.</p>
292
+ ) : (
293
+ <div className="grid gap-6 lg:grid-cols-[1fr_1.1fr]">
294
+ <div className={chartWrapperClass}>
295
+ <ResponsiveContainer width="100%" height="100%">
296
+ <PieChart>
297
+ <Pie
298
+ data={agentRows}
299
+ dataKey="tokens"
300
+ nameKey="label"
301
+ cx="50%"
302
+ cy="50%"
303
+ innerRadius={70}
304
+ outerRadius={105}
305
+ paddingAngle={agentRows.length > 1 ? 3 : 0}
306
+ >
307
+ {agentRows.map((row) => (
308
+ <Cell key={row.source} fill={row.color} />
309
+ ))}
310
+ </Pie>
311
+ <Tooltip
312
+ contentStyle={{
313
+ background: "rgba(2,6,23,0.95)",
314
+ border: "1px solid rgba(148,163,184,0.35)",
315
+ borderRadius: "12px",
316
+ }}
317
+ formatter={formatTokensTooltipWithValue}
318
+ />
319
+ </PieChart>
320
+ </ResponsiveContainer>
321
+ </div>
322
+
323
+ <div className="grid gap-2 sm:grid-cols-2">
324
+ {agentRows.map((row) => (
325
+ <article key={row.source} className="wrapped-tile">
326
+ <p className="text-xs uppercase tracking-[0.2em]" style={{ color: row.color }}>{row.icon} {row.label}</p>
327
+ <p className="mt-2 text-2xl font-semibold text-white">{formatTokens(row.tokens)}</p>
328
+ <p className="mt-1 text-xs text-slate-300">{row.percentage.toFixed(1)}% of total</p>
329
+ <p className="text-xs text-slate-400">{formatNumber(row.sessions)} sessions</p>
330
+ </article>
331
+ ))}
332
+ </div>
333
+ </div>
334
+ )}
335
+ </section>
336
+
337
+ <section className="wrapped-card wrapped-card-activity">
338
+ <header className="mb-6 flex flex-wrap items-end justify-between gap-3">
339
+ <div>
340
+ <p className="wrapped-kicker">Card 5</p>
341
+ <h2 className="wrapped-title">Daily Activity</h2>
342
+ </div>
343
+ <p className="text-sm text-slate-300">Heatmap of daily token usage</p>
344
+ </header>
345
+
346
+ {heatmap.length === 0 ? (
347
+ <p className="text-sm text-slate-300">No activity timeline available.</p>
348
+ ) : (
349
+ <div>
350
+ <div className="rounded-2xl border border-white/12 bg-slate-950/45 p-4">
351
+ <div
352
+ className="grid gap-1"
353
+ style={{
354
+ gridTemplateColumns: `repeat(${Math.max(heatmapWeeks.length, 1)}, minmax(0, 1fr))`,
355
+ }}
356
+ >
357
+ {heatmapWeeks.map((week, weekIndex) => (
358
+ <div key={`week-${weekIndex}`} className="grid grid-rows-7 gap-1">
359
+ {week.map((cell, dayIndex) => {
360
+ if (!cell) {
361
+ return <div key={`empty-${weekIndex}-${dayIndex}`} className="aspect-square w-full rounded-[4px] opacity-0" />;
362
+ }
363
+
364
+ const alpha = 0.12 + cell.intensity * 0.88;
365
+ const background =
366
+ cell.sessions > 0 ? `rgba(45,212,191,${alpha.toFixed(3)})` : "rgba(71,85,105,0.20)";
367
+
368
+ return (
369
+ <div
370
+ key={cell.date}
371
+ className="aspect-square w-full rounded-[4px]"
372
+ style={{ background }}
373
+ title={buildActivityTooltip(cell, dailyAgentTokensByDate)}
374
+ />
375
+ );
376
+ })}
377
+ </div>
378
+ ))}
379
+ </div>
380
+ </div>
381
+ <p className="mt-3 text-xs text-slate-400">{formatShortDate(dateFrom)} - {formatShortDate(dateTo)}</p>
382
+ </div>
383
+ )}
384
+ </section>
385
+
386
+ <section className="wrapped-card wrapped-card-cost">
387
+ <header className="mb-6 flex flex-wrap items-end justify-between gap-3">
388
+ <div>
389
+ <p className="wrapped-kicker">Card 6</p>
390
+ <h2 className="wrapped-title">Cost Breakdown</h2>
391
+ </div>
392
+ <p className="text-sm text-slate-300">Spend trend across the year</p>
393
+ </header>
394
+
395
+ <div className="grid gap-4 md:grid-cols-3">
396
+ <article className="wrapped-tile">
397
+ <p className="wrapped-label">Total Spend</p>
398
+ <p className="mt-2 text-4xl font-semibold text-white">{formatUsd(totalCostUsd)}</p>
399
+ </article>
400
+ <article className="wrapped-tile">
401
+ <p className="wrapped-label">Daily Average</p>
402
+ <p className="mt-2 text-3xl font-semibold text-white">{formatUsd(dailyAverageCostUsd)}</p>
403
+ </article>
404
+ <article className="wrapped-tile">
405
+ <p className="wrapped-label">Most Expensive Day</p>
406
+ <p className="mt-2 text-xl font-semibold text-white">
407
+ {mostExpensiveDay ? formatShortDate(mostExpensiveDay.date) : "-"}
408
+ </p>
409
+ <p className="mt-1 text-sm text-slate-300">
410
+ {mostExpensiveDay ? formatUsd(mostExpensiveDay.costUsd) : "No cost data"}
411
+ </p>
412
+ </article>
413
+ </div>
414
+
415
+ <div className="mt-6 h-56 sm:h-64">
416
+ <ResponsiveContainer width="100%" height="100%">
417
+ <AreaChart data={timeline}>
418
+ <defs>
419
+ <linearGradient id="costFill" x1="0" y1="0" x2="0" y2="1">
420
+ <stop offset="0%" stopColor="#38bdf8" stopOpacity={0.55} />
421
+ <stop offset="100%" stopColor="#38bdf8" stopOpacity={0.08} />
422
+ </linearGradient>
423
+ </defs>
424
+ <CartesianGrid stroke="rgba(148,163,184,0.2)" strokeDasharray="2 5" />
425
+ <XAxis dataKey="date" tick={{ fill: "#cbd5e1", fontSize: 11 }} tickLine={false} axisLine={false} />
426
+ <YAxis tick={{ fill: "#cbd5e1", fontSize: 11 }} tickLine={false} axisLine={false} />
427
+ <Tooltip
428
+ contentStyle={{
429
+ background: "rgba(2,6,23,0.95)",
430
+ border: "1px solid rgba(148,163,184,0.35)",
431
+ borderRadius: "12px",
432
+ }}
433
+ formatter={formatUsdTooltip}
434
+ labelFormatter={(value) => formatDate(String(value))}
435
+ />
436
+ <Area type="monotone" dataKey="costUsd" stroke="#38bdf8" fill="url(#costFill)" strokeWidth={2.5} />
437
+ </AreaChart>
438
+ </ResponsiveContainer>
439
+ </div>
440
+ </section>
441
+
442
+ <section className="wrapped-card wrapped-card-repos">
443
+ <header className="mb-6 flex flex-wrap items-end justify-between gap-3">
444
+ <div>
445
+ <p className="wrapped-kicker">Card 7</p>
446
+ <h2 className="wrapped-title">Your Top Repos</h2>
447
+ </div>
448
+ <p className="text-sm text-slate-300">By session volume and cost</p>
449
+ </header>
450
+
451
+ {topRepos.length === 0 ? (
452
+ <p className="text-sm text-slate-300">No repository usage found.</p>
453
+ ) : (
454
+ <div className="grid gap-6 lg:grid-cols-[1fr_1.2fr]">
455
+ <div className="space-y-2">
456
+ {topRepos.map((repo) => (
457
+ <article key={repo.repo} className="wrapped-tile">
458
+ <p className="truncate text-sm font-semibold text-white">{repo.repo}</p>
459
+ <div className="mt-2 flex items-center justify-between text-xs text-slate-300">
460
+ <span>{formatNumber(repo.sessions)} sessions</span>
461
+ <span>{formatUsd(repo.costUsd)}</span>
462
+ </div>
463
+ </article>
464
+ ))}
465
+ </div>
466
+
467
+ <div className={chartWrapperClass}>
468
+ <ResponsiveContainer width="100%" height="100%">
469
+ <BarChart data={topRepos} layout="vertical" margin={{ left: 12, right: 16 }}>
470
+ <CartesianGrid stroke="rgba(148,163,184,0.2)" strokeDasharray="2 5" />
471
+ <XAxis type="number" tick={{ fill: "#cbd5e1", fontSize: 11 }} tickLine={false} axisLine={false} />
472
+ <YAxis
473
+ dataKey="repo"
474
+ type="category"
475
+ tick={{ fill: "#e2e8f0", fontSize: 11 }}
476
+ tickLine={false}
477
+ axisLine={false}
478
+ width={140}
479
+ />
480
+ <Tooltip
481
+ contentStyle={{
482
+ background: "rgba(2,6,23,0.95)",
483
+ border: "1px solid rgba(148,163,184,0.35)",
484
+ borderRadius: "12px",
485
+ }}
486
+ formatter={formatNumberTooltip}
487
+ />
488
+ <Bar dataKey="sessions" fill="#14b8a6" radius={[0, 10, 10, 0]} />
489
+ </BarChart>
490
+ </ResponsiveContainer>
491
+ </div>
492
+ </div>
493
+ )}
494
+ </section>
495
+ </>
496
+ );
497
+ };
498
+
499
+ export default DashboardCharts;
@@ -0,0 +1,17 @@
1
+ interface EmptyStateProps {
2
+ title: string;
3
+ description?: string;
4
+ action?: React.ReactNode;
5
+ }
6
+
7
+ const EmptyState = ({ title, description, action }: EmptyStateProps) => {
8
+ return (
9
+ <div className="flex min-h-40 w-full flex-col items-center justify-center rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface-1)] px-6 py-10 text-center">
10
+ <h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
11
+ {description ? <p className="mt-2 max-w-xl text-sm text-[var(--text-secondary)]">{description}</p> : null}
12
+ {action ? <div className="mt-5">{action}</div> : null}
13
+ </div>
14
+ );
15
+ };
16
+
17
+ export default EmptyState;
@@ -0,0 +1,30 @@
1
+ const Sidebar = () => {
2
+ return (
3
+ <header className="pointer-events-none fixed inset-x-0 top-0 z-30 px-4 py-4 sm:px-6">
4
+ <div className="pointer-events-auto mx-auto flex w-full max-w-6xl items-center justify-between rounded-full border border-white/15 bg-slate-950/45 px-5 py-3 backdrop-blur-xl">
5
+ <div>
6
+ <p className="text-xs uppercase tracking-[0.22em] text-cyan-200/90">AI Wrapped</p>
7
+ <p className="text-sm font-medium tracking-tight text-white">Wrapped</p>
8
+ </div>
9
+
10
+ <button
11
+ type="button"
12
+ disabled
13
+ className="rounded-full border border-white/20 bg-white/10 p-2.5 text-slate-100 transition hover:border-cyan-200/70 hover:bg-cyan-300/20"
14
+ aria-label="Settings are unavailable in wrapped view"
15
+ >
16
+ <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" stroke="currentColor" strokeWidth="1.8">
17
+ <path
18
+ strokeLinecap="round"
19
+ strokeLinejoin="round"
20
+ d="M11.983 3.24a1.25 1.25 0 0 1 1.233.95l.328 1.311a1.25 1.25 0 0 0 .688.825l1.246.585a1.25 1.25 0 0 0 1.07.02l1.234-.555a1.25 1.25 0 0 1 1.55.488l.76 1.314a1.25 1.25 0 0 1-.203 1.614l-.96.96a1.25 1.25 0 0 0-.338 1.09l.207 1.364a1.25 1.25 0 0 0 .533.861l1.12.765a1.25 1.25 0 0 1 .42 1.645l-.76 1.314a1.25 1.25 0 0 1-1.504.573l-1.308-.393a1.25 1.25 0 0 0-1.067.17l-1.12.765a1.25 1.25 0 0 0-.533.862l-.207 1.363a1.25 1.25 0 0 1-1.49 1.035l-1.5-.304a1.25 1.25 0 0 1-.95-1.233v-1.345a1.25 1.25 0 0 0-.488-.989l-1.05-.807a1.25 1.25 0 0 0-1.041-.214l-1.308.393a1.25 1.25 0 0 1-1.504-.573l-.76-1.314a1.25 1.25 0 0 1 .42-1.645l1.12-.765a1.25 1.25 0 0 0 .533-.861l.207-1.364a1.25 1.25 0 0 0-.338-1.09l-.96-.96a1.25 1.25 0 0 1-.203-1.614l.76-1.314a1.25 1.25 0 0 1 1.55-.488l1.234.555a1.25 1.25 0 0 0 1.07-.02l1.246-.585a1.25 1.25 0 0 0 .688-.825l.328-1.311a1.25 1.25 0 0 1 1.232-.95Z"
21
+ />
22
+ <circle cx="12" cy="12" r="2.75" strokeLinecap="round" strokeLinejoin="round" />
23
+ </svg>
24
+ </button>
25
+ </div>
26
+ </header>
27
+ );
28
+ };
29
+
30
+ export default Sidebar;
@@ -0,0 +1,118 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { formatNumber, formatTokens, formatUsd } from "../lib/formatters";
3
+
4
+ const useInView = <T extends HTMLElement>(threshold = 0.4) => {
5
+ const ref = useRef<T | null>(null);
6
+ const [visible, setVisible] = useState(false);
7
+
8
+ useEffect(() => {
9
+ const node = ref.current;
10
+ if (!node || visible) return;
11
+
12
+ const observer = new IntersectionObserver(
13
+ ([entry]) => {
14
+ if (entry.isIntersecting) {
15
+ setVisible(true);
16
+ observer.disconnect();
17
+ }
18
+ },
19
+ { threshold },
20
+ );
21
+
22
+ observer.observe(node);
23
+ return () => observer.disconnect();
24
+ }, [threshold, visible]);
25
+
26
+ return { ref, visible };
27
+ };
28
+
29
+ interface AnimatedNumberProps {
30
+ value: number;
31
+ format: (value: number) => string;
32
+ durationMs?: number;
33
+ animate: boolean;
34
+ className?: string;
35
+ }
36
+
37
+ export const AnimatedNumber = ({
38
+ value,
39
+ format,
40
+ durationMs = 1000,
41
+ animate,
42
+ className,
43
+ }: AnimatedNumberProps) => {
44
+ const [displayValue, setDisplayValue] = useState(0);
45
+
46
+ useEffect(() => {
47
+ if (!animate) {
48
+ setDisplayValue(0);
49
+ return;
50
+ }
51
+
52
+ const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
53
+ if (prefersReducedMotion) {
54
+ setDisplayValue(value);
55
+ return;
56
+ }
57
+
58
+ const startTime = performance.now();
59
+ let frameId = 0;
60
+
61
+ const tick = (now: number) => {
62
+ const progress = Math.min((now - startTime) / durationMs, 1);
63
+ const eased = 1 - (1 - progress) ** 3;
64
+ setDisplayValue(value * eased);
65
+
66
+ if (progress < 1) {
67
+ frameId = window.requestAnimationFrame(tick);
68
+ }
69
+ };
70
+
71
+ frameId = window.requestAnimationFrame(tick);
72
+
73
+ return () => {
74
+ if (frameId) window.cancelAnimationFrame(frameId);
75
+ };
76
+ }, [animate, durationMs, value]);
77
+
78
+ return <span className={className}>{format(displayValue)}</span>;
79
+ };
80
+
81
+ interface StatsCardsProps {
82
+ totalSessions: number;
83
+ totalCostUsd: number;
84
+ totalTokens: number;
85
+ totalToolCalls: number;
86
+ }
87
+
88
+ const StatsCards = ({ totalSessions, totalCostUsd, totalTokens, totalToolCalls }: StatsCardsProps) => {
89
+ const { ref, visible } = useInView<HTMLDivElement>(0.35);
90
+
91
+ const stats = useMemo(
92
+ () => [
93
+ { label: "Sessions", value: totalSessions, format: formatNumber },
94
+ { label: "Spend", value: totalCostUsd, format: formatUsd },
95
+ { label: "Tokens", value: totalTokens, format: formatTokens },
96
+ { label: "Tool Calls", value: totalToolCalls, format: formatNumber },
97
+ ],
98
+ [totalCostUsd, totalSessions, totalTokens, totalToolCalls],
99
+ );
100
+
101
+ return (
102
+ <div ref={ref} className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
103
+ {stats.map((stat) => (
104
+ <article key={stat.label} className="wrapped-tile">
105
+ <p className="wrapped-label">{stat.label}</p>
106
+ <AnimatedNumber
107
+ value={stat.value}
108
+ format={stat.format}
109
+ animate={visible}
110
+ className="mt-2 block text-3xl font-semibold tracking-tight text-white sm:text-4xl"
111
+ />
112
+ </article>
113
+ ))}
114
+ </div>
115
+ );
116
+ };
117
+
118
+ export default StatsCards;