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,283 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { SessionSource } from "../shared/schema";
5
+ import type { AppSettings } from "../shared/types";
6
+
7
+ export interface ScanStateEntry {
8
+ source: SessionSource;
9
+ fileSize: number;
10
+ mtimeMs: number;
11
+ parsedAt: string;
12
+ }
13
+
14
+ export type ScanStateStore = Record<string, ScanStateEntry>;
15
+
16
+ export interface DayStats {
17
+ sessions: number;
18
+ messages: number;
19
+ toolCalls: number;
20
+ inputTokens: number;
21
+ outputTokens: number;
22
+ cacheReadTokens: number;
23
+ cacheWriteTokens: number;
24
+ reasoningTokens: number;
25
+ costUsd: number;
26
+ durationMs: number;
27
+ }
28
+
29
+ export interface DailyAggregateEntry {
30
+ bySource: Record<string, DayStats>;
31
+ byModel: Record<string, DayStats>;
32
+ byRepo: Record<string, DayStats>;
33
+ totals: DayStats;
34
+ }
35
+
36
+ export type DailyStore = Record<string, DailyAggregateEntry>;
37
+
38
+ const DATA_DIR = join(homedir(), ".ai-wrapped");
39
+ const SCAN_STATE_PATH = join(DATA_DIR, "scan-state.json");
40
+ const DAILY_PATH = join(DATA_DIR, "daily.json");
41
+ const SETTINGS_PATH = join(DATA_DIR, "settings.json");
42
+
43
+ const DEFAULT_SETTINGS: AppSettings = {
44
+ scanOnLaunch: true,
45
+ scanIntervalMinutes: 5,
46
+ theme: "system",
47
+ customPaths: {},
48
+ };
49
+
50
+ let scanStateCache: ScanStateStore | null = null;
51
+ let dailyCache: DailyStore | null = null;
52
+ let settingsCache: AppSettings | null = null;
53
+
54
+ const clone = <T>(value: T): T => structuredClone(value);
55
+
56
+ const ensureDataDir = () => {
57
+ mkdirSync(DATA_DIR, { recursive: true });
58
+ };
59
+
60
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
61
+ Boolean(value) && typeof value === "object" && !Array.isArray(value);
62
+
63
+ const toNumber = (value: unknown): number =>
64
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
65
+
66
+ const toStringOr = (value: unknown, fallback: string): string =>
67
+ typeof value === "string" && value.length > 0 ? value : fallback;
68
+
69
+ const readJson = async <T>(path: string, fallback: T): Promise<T> => {
70
+ try {
71
+ const text = await Bun.file(path).text();
72
+ const parsed = JSON.parse(text) as unknown;
73
+ return parsed as T;
74
+ } catch {
75
+ return fallback;
76
+ }
77
+ };
78
+
79
+ const writeJson = async <T>(path: string, value: T): Promise<void> => {
80
+ ensureDataDir();
81
+ await Bun.write(path, `${JSON.stringify(value, null, 2)}\n`);
82
+ };
83
+
84
+ export const createEmptyDayStats = (): DayStats => ({
85
+ sessions: 0,
86
+ messages: 0,
87
+ toolCalls: 0,
88
+ inputTokens: 0,
89
+ outputTokens: 0,
90
+ cacheReadTokens: 0,
91
+ cacheWriteTokens: 0,
92
+ reasoningTokens: 0,
93
+ costUsd: 0,
94
+ durationMs: 0,
95
+ });
96
+
97
+ const normalizeDayStats = (value: unknown): DayStats => {
98
+ if (!isRecord(value)) {
99
+ return createEmptyDayStats();
100
+ }
101
+
102
+ return {
103
+ sessions: toNumber(value.sessions),
104
+ messages: toNumber(value.messages),
105
+ toolCalls: toNumber(value.toolCalls),
106
+ inputTokens: toNumber(value.inputTokens),
107
+ outputTokens: toNumber(value.outputTokens),
108
+ cacheReadTokens: toNumber(value.cacheReadTokens),
109
+ cacheWriteTokens: toNumber(value.cacheWriteTokens),
110
+ reasoningTokens: toNumber(value.reasoningTokens),
111
+ costUsd: toNumber(value.costUsd),
112
+ durationMs: toNumber(value.durationMs),
113
+ };
114
+ };
115
+
116
+ const normalizeDailyStore = (value: unknown): DailyStore => {
117
+ if (!isRecord(value)) {
118
+ return {};
119
+ }
120
+
121
+ const output: DailyStore = {};
122
+
123
+ for (const [date, rawEntry] of Object.entries(value)) {
124
+ if (!isRecord(rawEntry)) {
125
+ continue;
126
+ }
127
+
128
+ const bySource: Record<string, DayStats> = {};
129
+ if (isRecord(rawEntry.bySource)) {
130
+ for (const [source, rawStats] of Object.entries(rawEntry.bySource)) {
131
+ bySource[source] = normalizeDayStats(rawStats);
132
+ }
133
+ }
134
+
135
+ const byModel: Record<string, DayStats> = {};
136
+ if (isRecord(rawEntry.byModel)) {
137
+ for (const [model, rawStats] of Object.entries(rawEntry.byModel)) {
138
+ byModel[model] = normalizeDayStats(rawStats);
139
+ }
140
+ }
141
+
142
+ const byRepo: Record<string, DayStats> = {};
143
+ if (isRecord(rawEntry.byRepo)) {
144
+ for (const [repo, rawStats] of Object.entries(rawEntry.byRepo)) {
145
+ byRepo[repo] = normalizeDayStats(rawStats);
146
+ }
147
+ }
148
+
149
+ output[date] = {
150
+ bySource,
151
+ byModel,
152
+ byRepo,
153
+ totals: normalizeDayStats(rawEntry.totals),
154
+ };
155
+ }
156
+
157
+ return output;
158
+ };
159
+
160
+ const normalizeScanState = (value: unknown): ScanStateStore => {
161
+ if (!isRecord(value)) {
162
+ return {};
163
+ }
164
+
165
+ const output: ScanStateStore = {};
166
+
167
+ for (const [filePath, rawState] of Object.entries(value)) {
168
+ if (!isRecord(rawState)) {
169
+ continue;
170
+ }
171
+
172
+ const source = rawState.source;
173
+ if (typeof source !== "string") {
174
+ continue;
175
+ }
176
+
177
+ output[filePath] = {
178
+ source: source as SessionSource,
179
+ fileSize: toNumber(rawState.fileSize),
180
+ mtimeMs: toNumber(rawState.mtimeMs),
181
+ parsedAt: toStringOr(rawState.parsedAt, new Date(0).toISOString()),
182
+ };
183
+ }
184
+
185
+ return output;
186
+ };
187
+
188
+ const normalizeSettings = (value: unknown): AppSettings => {
189
+ if (!isRecord(value)) {
190
+ return clone(DEFAULT_SETTINGS);
191
+ }
192
+
193
+ const customPathsInput = isRecord(value.customPaths) ? value.customPaths : {};
194
+ const customPaths: AppSettings["customPaths"] = {};
195
+
196
+ for (const [source, pathValue] of Object.entries(customPathsInput)) {
197
+ if (typeof pathValue === "string") {
198
+ customPaths[source as SessionSource] = pathValue;
199
+ }
200
+ }
201
+
202
+ return {
203
+ scanOnLaunch:
204
+ typeof value.scanOnLaunch === "boolean" ? value.scanOnLaunch : DEFAULT_SETTINGS.scanOnLaunch,
205
+ scanIntervalMinutes:
206
+ typeof value.scanIntervalMinutes === "number" && Number.isFinite(value.scanIntervalMinutes)
207
+ ? Math.max(1, Math.floor(value.scanIntervalMinutes))
208
+ : DEFAULT_SETTINGS.scanIntervalMinutes,
209
+ theme:
210
+ value.theme === "system" || value.theme === "light" || value.theme === "dark"
211
+ ? value.theme
212
+ : DEFAULT_SETTINGS.theme,
213
+ customPaths,
214
+ };
215
+ };
216
+
217
+ export const readScanState = async (): Promise<ScanStateStore> => {
218
+ if (scanStateCache === null) {
219
+ const raw = await readJson<unknown>(SCAN_STATE_PATH, {});
220
+ scanStateCache = normalizeScanState(raw);
221
+ }
222
+
223
+ return clone(scanStateCache);
224
+ };
225
+
226
+ export const writeScanState = async (state: ScanStateStore): Promise<void> => {
227
+ scanStateCache = normalizeScanState(state);
228
+ await writeJson(SCAN_STATE_PATH, scanStateCache);
229
+ };
230
+
231
+ export const readDailyStore = async (): Promise<DailyStore> => {
232
+ if (dailyCache === null) {
233
+ const raw = await readJson<unknown>(DAILY_PATH, {});
234
+ dailyCache = normalizeDailyStore(raw);
235
+ }
236
+
237
+ return clone(dailyCache);
238
+ };
239
+
240
+ export const writeDailyStore = async (daily: DailyStore): Promise<void> => {
241
+ dailyCache = normalizeDailyStore(daily);
242
+ await writeJson(DAILY_PATH, dailyCache);
243
+ };
244
+
245
+ export const dailyStoreMissingRepoDimension = async (): Promise<boolean> => {
246
+ const raw = await readJson<unknown>(DAILY_PATH, {});
247
+ if (!isRecord(raw)) {
248
+ return false;
249
+ }
250
+
251
+ for (const rawEntry of Object.values(raw)) {
252
+ if (!isRecord(rawEntry)) {
253
+ continue;
254
+ }
255
+
256
+ if (!Object.prototype.hasOwnProperty.call(rawEntry, "byRepo")) {
257
+ return true;
258
+ }
259
+ }
260
+
261
+ return false;
262
+ };
263
+
264
+ export const getSettings = async (): Promise<AppSettings> => {
265
+ if (settingsCache === null) {
266
+ const raw = await readJson<unknown>(SETTINGS_PATH, DEFAULT_SETTINGS);
267
+ settingsCache = normalizeSettings(raw);
268
+ }
269
+
270
+ return clone(settingsCache);
271
+ };
272
+
273
+ export const setSettings = async (settings: AppSettings): Promise<void> => {
274
+ settingsCache = normalizeSettings(settings);
275
+ await writeJson(SETTINGS_PATH, settingsCache);
276
+ };
277
+
278
+ export const paths = {
279
+ dataDir: DATA_DIR,
280
+ scanState: SCAN_STATE_PATH,
281
+ daily: DAILY_PATH,
282
+ settings: SETTINGS_PATH,
283
+ };
@@ -0,0 +1,42 @@
1
+ import { useEffect } from "react";
2
+ import Dashboard from "./components/Dashboard";
3
+ import Sidebar from "./components/Sidebar";
4
+ import { useRPC } from "./hooks/useRPC";
5
+
6
+ type ThemeMode = "system" | "light" | "dark";
7
+
8
+ const applyTheme = (theme: ThemeMode) => {
9
+ document.documentElement.dataset.theme = theme;
10
+ };
11
+
12
+ const App = () => {
13
+ const rpc = useRPC();
14
+
15
+ useEffect(() => {
16
+ applyTheme("system");
17
+ }, []);
18
+
19
+ useEffect(() => {
20
+ const listener = (payload: unknown) => {
21
+ if (!payload || typeof payload !== "object") return;
22
+ const theme = (payload as { theme?: ThemeMode }).theme;
23
+ if (theme === "system" || theme === "light" || theme === "dark") {
24
+ applyTheme(theme);
25
+ }
26
+ };
27
+
28
+ rpc.addMessageListener("themeChanged", listener);
29
+ return () => {
30
+ rpc.removeMessageListener("themeChanged", listener);
31
+ };
32
+ }, [rpc]);
33
+
34
+ return (
35
+ <div className="h-screen overflow-hidden bg-[var(--surface-0)] text-[var(--text-primary)]">
36
+ <Sidebar />
37
+ <Dashboard />
38
+ </div>
39
+ );
40
+ };
41
+
42
+ export default App;
@@ -0,0 +1,17 @@
1
+ import type { SessionSource } from "@shared/schema";
2
+ import { SOURCE_COLORS, SOURCE_LABELS } from "../lib/constants";
3
+
4
+ interface AgentBadgeProps {
5
+ source: SessionSource;
6
+ }
7
+
8
+ const AgentBadge = ({ source }: AgentBadgeProps) => {
9
+ return (
10
+ <span className="inline-flex items-center gap-2 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-2)] px-2.5 py-1 text-xs font-semibold text-[var(--text-secondary)]">
11
+ <span className="h-2 w-2 rounded-full" style={{ backgroundColor: SOURCE_COLORS[source] }} />
12
+ {SOURCE_LABELS[source]}
13
+ </span>
14
+ );
15
+ };
16
+
17
+ export default AgentBadge;
@@ -0,0 +1,229 @@
1
+ import { useEffect, useRef, useState, type ChangeEvent } from "react";
2
+ import DashboardCharts from "./DashboardCharts";
3
+ import EmptyState from "./EmptyState";
4
+ import StatsCards, { AnimatedNumber } from "./StatsCards";
5
+ import { useDashboardData, type DashboardDateRange } from "../hooks/useDashboardData";
6
+ import { formatDate, formatDuration, formatNumber } from "../lib/formatters";
7
+
8
+ const useInView = <T extends HTMLElement>(threshold = 0.35) => {
9
+ const ref = useRef<T | null>(null);
10
+ const [visible, setVisible] = useState(false);
11
+
12
+ useEffect(() => {
13
+ const node = ref.current;
14
+ if (!node || visible) return;
15
+
16
+ const observer = new IntersectionObserver(
17
+ ([entry]) => {
18
+ if (entry.isIntersecting) {
19
+ setVisible(true);
20
+ observer.disconnect();
21
+ }
22
+ },
23
+ { threshold },
24
+ );
25
+
26
+ observer.observe(node);
27
+ return () => observer.disconnect();
28
+ }, [threshold, visible]);
29
+
30
+ return { ref, visible };
31
+ };
32
+
33
+ const clampPercentage = (value: number): number => Math.max(0, Math.min(100, value));
34
+
35
+ const Dashboard = () => {
36
+ const {
37
+ dateFrom,
38
+ dateTo,
39
+ summary,
40
+ timeline,
41
+ loading,
42
+ error,
43
+ refresh,
44
+ totals,
45
+ modelBreakdown,
46
+ agentBreakdown,
47
+ topRepos,
48
+ selectedRange,
49
+ setSelectedRange,
50
+ rangeOptions,
51
+ dailyAgentTokensByDate,
52
+ } = useDashboardData();
53
+
54
+ const timeCard = useInView<HTMLDivElement>(0.4);
55
+
56
+ if (loading && !summary) {
57
+ return (
58
+ <div className="wrapped-scroll">
59
+ <section className="wrapped-card wrapped-card-loading">
60
+ <EmptyState title="Building your coding story" description="Loading annual summary and timeline." />
61
+ </section>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ if (error && !summary) {
67
+ return (
68
+ <div className="wrapped-scroll">
69
+ <section className="wrapped-card wrapped-card-loading">
70
+ <EmptyState title="Unable to build wrapped view" description={error} />
71
+ <button type="button" onClick={() => void refresh()} className="wrapped-button mt-4">
72
+ Retry
73
+ </button>
74
+ </section>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ const activeDayCoverage =
80
+ totals.dateSpanDays > 0 ? clampPercentage((totals.activeDays / totals.dateSpanDays) * 100) : 0;
81
+ const totalHours = totals.totalDurationMs / (60 * 60 * 1000);
82
+ const totalDays = totals.totalDurationMs / (24 * 60 * 60 * 1000);
83
+ const ringRadius = 58;
84
+ const ringCircumference = 2 * Math.PI * ringRadius;
85
+ const ringOffset = ringCircumference - (activeDayCoverage / 100) * ringCircumference;
86
+ const handleRangeChange = (event: ChangeEvent<HTMLSelectElement>) => {
87
+ const next = event.target.value as DashboardDateRange;
88
+ if (!rangeOptions.some((option) => option.value === next)) return;
89
+ setSelectedRange(next);
90
+ };
91
+
92
+ return (
93
+ <div className="wrapped-scroll">
94
+ <div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 pb-12 pt-20 sm:px-6">
95
+ <section className="wrapped-tile self-end p-3 sm:p-4">
96
+ <label className="flex items-center gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
97
+ Range
98
+ <select
99
+ value={selectedRange}
100
+ onChange={handleRangeChange}
101
+ aria-label="Dashboard range"
102
+ className="min-w-40 rounded-lg border border-white/20 bg-slate-950/65 px-3 py-2 text-sm font-medium normal-case tracking-normal text-slate-100 outline-none transition focus:border-sky-300"
103
+ >
104
+ {rangeOptions.map((option) => (
105
+ <option key={option.value} value={option.value} className="bg-slate-950 text-slate-100">
106
+ {option.label}
107
+ </option>
108
+ ))}
109
+ </select>
110
+ </label>
111
+ </section>
112
+
113
+ <section className="wrapped-card wrapped-card-hero">
114
+ <header className="mb-6">
115
+ <p className="wrapped-kicker">Your Year In Code</p>
116
+ <h1 className="text-4xl font-semibold tracking-[-0.03em] text-white sm:text-6xl">Your AI Coding Year</h1>
117
+ <p className="mt-3 text-sm text-slate-200/90">
118
+ {formatDate(dateFrom)} - {formatDate(dateTo)}
119
+ </p>
120
+ </header>
121
+
122
+ <StatsCards
123
+ totalSessions={totals.totalSessions}
124
+ totalCostUsd={totals.totalCostUsd}
125
+ totalTokens={totals.totalTokens}
126
+ totalToolCalls={summary?.totals.toolCalls ?? 0}
127
+ />
128
+ </section>
129
+
130
+ <section ref={timeCard.ref} className="wrapped-card wrapped-card-time">
131
+ <header className="mb-6 flex flex-wrap items-end justify-between gap-3">
132
+ <div>
133
+ <p className="wrapped-kicker">Card 2</p>
134
+ <h2 className="wrapped-title">Time Spent Coding with AI</h2>
135
+ </div>
136
+ <p className="text-sm text-slate-300">Active on {formatNumber(totals.activeDays)} days</p>
137
+ </header>
138
+
139
+ <div className="grid gap-6 lg:grid-cols-[1.3fr_1fr]">
140
+ <div className="grid gap-3 sm:grid-cols-2">
141
+ <article className="wrapped-tile">
142
+ <p className="wrapped-label">Total Hours</p>
143
+ <AnimatedNumber
144
+ value={totalHours}
145
+ animate={timeCard.visible}
146
+ format={(value) => `${value.toFixed(1)}h`}
147
+ className="mt-2 block text-4xl font-semibold text-white"
148
+ />
149
+ <p className="mt-2 text-xs text-slate-300">{totalDays.toFixed(1)} total days of coding time</p>
150
+ </article>
151
+
152
+ <article className="wrapped-tile">
153
+ <p className="wrapped-label">Average Session</p>
154
+ <p className="mt-2 text-3xl font-semibold text-white">{formatDuration(totals.averageSessionDurationMs)}</p>
155
+ <p className="mt-2 text-xs text-slate-300">Per session across the full range</p>
156
+ </article>
157
+
158
+ <article className="wrapped-tile sm:col-span-2">
159
+ <p className="wrapped-label">Longest Session Highlight</p>
160
+ <p className="mt-2 text-3xl font-semibold text-white">
161
+ {formatDuration(totals.longestSessionEstimateMs)}
162
+ </p>
163
+ <p className="mt-2 text-xs text-slate-300">Estimated from daily totals and session counts</p>
164
+ </article>
165
+ </div>
166
+
167
+ <article className="wrapped-tile flex flex-col items-center justify-center text-center">
168
+ <svg width="152" height="152" viewBox="0 0 152 152" className="overflow-visible">
169
+ <circle
170
+ cx="76"
171
+ cy="76"
172
+ r={ringRadius}
173
+ fill="none"
174
+ stroke="rgba(148,163,184,0.25)"
175
+ strokeWidth="12"
176
+ />
177
+ <circle
178
+ cx="76"
179
+ cy="76"
180
+ r={ringRadius}
181
+ fill="none"
182
+ stroke="url(#ringGradient)"
183
+ strokeWidth="12"
184
+ strokeLinecap="round"
185
+ strokeDasharray={ringCircumference}
186
+ strokeDashoffset={timeCard.visible ? ringOffset : ringCircumference}
187
+ style={{
188
+ transition: "stroke-dashoffset 1000ms cubic-bezier(0.22, 1, 0.36, 1)",
189
+ transformOrigin: "50% 50%",
190
+ transform: "rotate(-90deg)",
191
+ }}
192
+ />
193
+ <defs>
194
+ <linearGradient id="ringGradient" x1="0%" y1="0%" x2="100%" y2="100%">
195
+ <stop offset="0%" stopColor="#22d3ee" />
196
+ <stop offset="100%" stopColor="#0ea5e9" />
197
+ </linearGradient>
198
+ </defs>
199
+ </svg>
200
+
201
+ <AnimatedNumber
202
+ value={activeDayCoverage}
203
+ animate={timeCard.visible}
204
+ format={(value) => `${value.toFixed(1)}%`}
205
+ className="mt-4 block text-4xl font-semibold text-white"
206
+ />
207
+ <p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-300">Days with activity</p>
208
+ </article>
209
+ </div>
210
+ </section>
211
+
212
+ <DashboardCharts
213
+ dateFrom={dateFrom}
214
+ dateTo={dateTo}
215
+ modelBreakdown={modelBreakdown}
216
+ agentBreakdown={agentBreakdown}
217
+ timeline={timeline}
218
+ dailyAgentTokensByDate={dailyAgentTokensByDate}
219
+ topRepos={topRepos}
220
+ totalCostUsd={totals.totalCostUsd}
221
+ dailyAverageCostUsd={totals.dailyAverageCostUsd}
222
+ mostExpensiveDay={totals.mostExpensiveDay}
223
+ />
224
+ </div>
225
+ </div>
226
+ );
227
+ };
228
+
229
+ export default Dashboard;