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.
- package/.0spec/config.toml +50 -0
- package/.0spec/flows/ai-stats-build.toml +291 -0
- package/.0spec/flows/ai-stats-fix.toml +285 -0
- package/.0spec/flows/ai-stats.toml +400 -0
- package/.github/workflows/publish.yml +28 -0
- package/CLAUDE.md +111 -0
- package/README.md +64 -0
- package/bun.lock +635 -0
- package/electrobun.config.ts +25 -0
- package/package.json +36 -0
- package/public/tray-icon.png +0 -0
- package/src/bun/aggregator.test.ts +49 -0
- package/src/bun/aggregator.ts +130 -0
- package/src/bun/discovery/claude.ts +18 -0
- package/src/bun/discovery/codex.ts +20 -0
- package/src/bun/discovery/copilot.ts +13 -0
- package/src/bun/discovery/droid.ts +13 -0
- package/src/bun/discovery/gemini.ts +13 -0
- package/src/bun/discovery/index.ts +28 -0
- package/src/bun/discovery/opencode.ts +13 -0
- package/src/bun/discovery/types.ts +13 -0
- package/src/bun/discovery/utils.ts +48 -0
- package/src/bun/index.ts +722 -0
- package/src/bun/normalizer.test.ts +101 -0
- package/src/bun/normalizer.ts +454 -0
- package/src/bun/parsers/claude.ts +234 -0
- package/src/bun/parsers/codex.test.ts +180 -0
- package/src/bun/parsers/codex.ts +435 -0
- package/src/bun/parsers/copilot.ts +4 -0
- package/src/bun/parsers/droid.ts +4 -0
- package/src/bun/parsers/gemini.ts +4 -0
- package/src/bun/parsers/generic.test.ts +97 -0
- package/src/bun/parsers/generic.ts +260 -0
- package/src/bun/parsers/index.ts +37 -0
- package/src/bun/parsers/opencode.ts +4 -0
- package/src/bun/parsers/types.ts +23 -0
- package/src/bun/pricing.ts +52 -0
- package/src/bun/scan.ts +77 -0
- package/src/bun/session-schema.ts +1 -0
- package/src/bun/store.ts +283 -0
- package/src/mainview/App.tsx +42 -0
- package/src/mainview/components/AgentBadge.tsx +17 -0
- package/src/mainview/components/Dashboard.tsx +229 -0
- package/src/mainview/components/DashboardCharts.tsx +499 -0
- package/src/mainview/components/EmptyState.tsx +17 -0
- package/src/mainview/components/Sidebar.tsx +30 -0
- package/src/mainview/components/StatsCards.tsx +118 -0
- package/src/mainview/hooks/useDashboardData.ts +315 -0
- package/src/mainview/hooks/useRPC.ts +29 -0
- package/src/mainview/index.css +195 -0
- package/src/mainview/index.html +12 -0
- package/src/mainview/index.ts +12 -0
- package/src/mainview/lib/constants.ts +32 -0
- package/src/mainview/lib/formatters.ts +82 -0
- package/src/shared/constants.ts +1 -0
- package/src/shared/schema.ts +71 -0
- package/src/shared/session-types.ts +61 -0
- package/src/shared/types.ts +59 -0
- package/src/types/electrobun-bun.d.ts +117 -0
- package/src/types/electrobun-root.d.ts +3 -0
- package/src/types/electrobun-view.d.ts +38 -0
- package/tsconfig.json +18 -0
- package/tsconfig.typecheck.json +11 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { DashboardSummary, DailyAggregate, SessionSource, TokenUsage } from "@shared/schema";
|
|
3
|
+
import { SESSION_SOURCES } from "@shared/schema";
|
|
4
|
+
import { SOURCE_LABELS } from "../lib/constants";
|
|
5
|
+
import { rpcRequest, useRPC } from "./useRPC";
|
|
6
|
+
|
|
7
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const MIN_SELECTABLE_YEAR = 2024;
|
|
9
|
+
|
|
10
|
+
const daysAgoISO = (daysAgo: number): string => {
|
|
11
|
+
const date = new Date();
|
|
12
|
+
date.setHours(0, 0, 0, 0);
|
|
13
|
+
date.setDate(date.getDate() - daysAgo);
|
|
14
|
+
return date.toISOString().slice(0, 10);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const todayISO = (): string => new Date().toISOString().slice(0, 10);
|
|
18
|
+
|
|
19
|
+
const tokenTotal = (tokens: TokenUsage): number =>
|
|
20
|
+
tokens.inputTokens +
|
|
21
|
+
tokens.outputTokens +
|
|
22
|
+
tokens.cacheReadTokens +
|
|
23
|
+
tokens.cacheWriteTokens +
|
|
24
|
+
tokens.reasoningTokens;
|
|
25
|
+
|
|
26
|
+
const rangeLengthDays = (dateFrom: string, dateTo: string): number => {
|
|
27
|
+
const from = Date.parse(`${dateFrom}T00:00:00Z`);
|
|
28
|
+
const to = Date.parse(`${dateTo}T00:00:00Z`);
|
|
29
|
+
if (Number.isNaN(from) || Number.isNaN(to) || to < from) return 0;
|
|
30
|
+
return Math.floor((to - from) / ONE_DAY_MS) + 1;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const currentYear = (): number => new Date().getFullYear();
|
|
34
|
+
|
|
35
|
+
export type DashboardDateRange = "last365" | `year:${number}`;
|
|
36
|
+
|
|
37
|
+
export interface DashboardDateRangeOption {
|
|
38
|
+
value: DashboardDateRange;
|
|
39
|
+
label: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const parseYearSelection = (selection: DashboardDateRange): number | null => {
|
|
43
|
+
if (!selection.startsWith("year:")) return null;
|
|
44
|
+
const parsed = Number(selection.slice(5));
|
|
45
|
+
return Number.isInteger(parsed) ? parsed : null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const buildRangeOptions = (): DashboardDateRangeOption[] => {
|
|
49
|
+
const thisYear = currentYear();
|
|
50
|
+
const options: DashboardDateRangeOption[] = [{ value: "last365", label: "Last 365 days" }];
|
|
51
|
+
for (let year = thisYear; year >= MIN_SELECTABLE_YEAR; year -= 1) {
|
|
52
|
+
options.push({
|
|
53
|
+
value: `year:${year}` as DashboardDateRange,
|
|
54
|
+
label: year === thisYear ? `${year} (Current year)` : String(year),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return options;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const resolveDateRange = (selection: DashboardDateRange): { dateFrom: string; dateTo: string } => {
|
|
61
|
+
const today = todayISO();
|
|
62
|
+
const selectedYear = parseYearSelection(selection);
|
|
63
|
+
|
|
64
|
+
if (selectedYear === null) {
|
|
65
|
+
return {
|
|
66
|
+
dateFrom: daysAgoISO(364),
|
|
67
|
+
dateTo: today,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
dateFrom: `${selectedYear}-01-01`,
|
|
73
|
+
dateTo: selectedYear === currentYear() ? today : `${selectedYear}-12-31`,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface TimelinePoint {
|
|
78
|
+
date: string;
|
|
79
|
+
sessions: number;
|
|
80
|
+
tokens: number;
|
|
81
|
+
costUsd: number;
|
|
82
|
+
durationMs: number;
|
|
83
|
+
messages: number;
|
|
84
|
+
toolCalls: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AgentBreakdown {
|
|
88
|
+
source: SessionSource;
|
|
89
|
+
label: string;
|
|
90
|
+
sessions: number;
|
|
91
|
+
tokens: number;
|
|
92
|
+
costUsd: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ModelBreakdown {
|
|
96
|
+
model: string;
|
|
97
|
+
sessions: number;
|
|
98
|
+
tokens: number;
|
|
99
|
+
costUsd: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type DailyAgentTokensByDate = Record<string, Record<SessionSource, number>>;
|
|
103
|
+
|
|
104
|
+
export interface DashboardTotals {
|
|
105
|
+
totalSessions: number;
|
|
106
|
+
totalTokens: number;
|
|
107
|
+
totalCostUsd: number;
|
|
108
|
+
totalDurationMs: number;
|
|
109
|
+
averageSessionDurationMs: number;
|
|
110
|
+
longestSessionEstimateMs: number;
|
|
111
|
+
activeDays: number;
|
|
112
|
+
dateSpanDays: number;
|
|
113
|
+
dailyAverageCostUsd: number;
|
|
114
|
+
mostExpensiveDay: TimelinePoint | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const emptyTotals: DashboardTotals = {
|
|
118
|
+
totalSessions: 0,
|
|
119
|
+
totalTokens: 0,
|
|
120
|
+
totalCostUsd: 0,
|
|
121
|
+
totalDurationMs: 0,
|
|
122
|
+
averageSessionDurationMs: 0,
|
|
123
|
+
longestSessionEstimateMs: 0,
|
|
124
|
+
activeDays: 0,
|
|
125
|
+
dateSpanDays: 0,
|
|
126
|
+
dailyAverageCostUsd: 0,
|
|
127
|
+
mostExpensiveDay: null,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const createEmptySourceTokenMap = (): Record<SessionSource, number> => ({
|
|
131
|
+
claude: 0,
|
|
132
|
+
codex: 0,
|
|
133
|
+
gemini: 0,
|
|
134
|
+
opencode: 0,
|
|
135
|
+
droid: 0,
|
|
136
|
+
copilot: 0,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const buildDailyAgentTokensByDate = (
|
|
140
|
+
rowsBySource: Array<{ source: SessionSource; rows: DailyAggregate[] }>,
|
|
141
|
+
): DailyAgentTokensByDate => {
|
|
142
|
+
const byDate: DailyAgentTokensByDate = {};
|
|
143
|
+
|
|
144
|
+
for (const { source, rows } of rowsBySource) {
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
const current = byDate[row.date] ?? createEmptySourceTokenMap();
|
|
147
|
+
current[source] = tokenTotal(row.tokens);
|
|
148
|
+
byDate[row.date] = current;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return byDate;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const useDashboardData = () => {
|
|
156
|
+
const rpc = useRPC();
|
|
157
|
+
const [selectedRange, setSelectedRange] = useState<DashboardDateRange>("last365");
|
|
158
|
+
const rangeOptions = useMemo<DashboardDateRangeOption[]>(() => buildRangeOptions(), []);
|
|
159
|
+
const { dateFrom, dateTo } = useMemo(() => resolveDateRange(selectedRange), [selectedRange]);
|
|
160
|
+
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
|
161
|
+
const [timeline, setTimeline] = useState<DailyAggregate[]>([]);
|
|
162
|
+
const [dailyAgentTokensByDate, setDailyAgentTokensByDate] = useState<DailyAgentTokensByDate>({});
|
|
163
|
+
const [loading, setLoading] = useState<boolean>(true);
|
|
164
|
+
const [error, setError] = useState<string | null>(null);
|
|
165
|
+
|
|
166
|
+
const refresh = useCallback(async () => {
|
|
167
|
+
setLoading(true);
|
|
168
|
+
setError(null);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const [summaryResult, timelineResult, ...timelineBySourceResults] = await Promise.all([
|
|
172
|
+
rpcRequest("getDashboardSummary", { dateFrom, dateTo }),
|
|
173
|
+
rpcRequest("getDailyTimeline", { dateFrom, dateTo }),
|
|
174
|
+
...SESSION_SOURCES.map((source) => rpcRequest("getDailyTimeline", { dateFrom, dateTo, source })),
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
setSummary(summaryResult);
|
|
178
|
+
setTimeline(timelineResult);
|
|
179
|
+
setDailyAgentTokensByDate(
|
|
180
|
+
buildDailyAgentTokensByDate(
|
|
181
|
+
timelineBySourceResults.map((rows, index) => ({
|
|
182
|
+
source: SESSION_SOURCES[index],
|
|
183
|
+
rows,
|
|
184
|
+
})),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
} catch (caught) {
|
|
188
|
+
setError(caught instanceof Error ? caught.message : "Failed to load dashboard");
|
|
189
|
+
} finally {
|
|
190
|
+
setLoading(false);
|
|
191
|
+
}
|
|
192
|
+
}, [dateFrom, dateTo]);
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
void refresh();
|
|
196
|
+
}, [refresh]);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const listener = () => {
|
|
200
|
+
void refresh();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
rpc.addMessageListener("sessionsUpdated", listener);
|
|
204
|
+
rpc.addMessageListener("scanCompleted", listener);
|
|
205
|
+
|
|
206
|
+
return () => {
|
|
207
|
+
rpc.removeMessageListener("sessionsUpdated", listener);
|
|
208
|
+
rpc.removeMessageListener("scanCompleted", listener);
|
|
209
|
+
};
|
|
210
|
+
}, [refresh, rpc]);
|
|
211
|
+
|
|
212
|
+
const timelinePoints = useMemo<TimelinePoint[]>(
|
|
213
|
+
() =>
|
|
214
|
+
timeline
|
|
215
|
+
.map((entry) => ({
|
|
216
|
+
date: entry.date,
|
|
217
|
+
sessions: entry.sessionCount,
|
|
218
|
+
tokens: tokenTotal(entry.tokens),
|
|
219
|
+
costUsd: entry.costUsd,
|
|
220
|
+
durationMs: entry.totalDurationMs,
|
|
221
|
+
messages: entry.messageCount,
|
|
222
|
+
toolCalls: entry.toolCallCount,
|
|
223
|
+
}))
|
|
224
|
+
.sort((left, right) => left.date.localeCompare(right.date)),
|
|
225
|
+
[timeline],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const totals = useMemo<DashboardTotals>(() => {
|
|
229
|
+
if (!summary) return emptyTotals;
|
|
230
|
+
|
|
231
|
+
const spanDays = rangeLengthDays(dateFrom, dateTo);
|
|
232
|
+
const activeDays = timelinePoints.filter((entry) => entry.sessions > 0).length;
|
|
233
|
+
const mostExpensiveDay =
|
|
234
|
+
timelinePoints.length === 0
|
|
235
|
+
? null
|
|
236
|
+
: timelinePoints.reduce((max, entry) => (entry.costUsd > max.costUsd ? entry : max), timelinePoints[0]);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
totalSessions: summary.totals.sessions,
|
|
240
|
+
totalTokens: tokenTotal(summary.totals.tokens),
|
|
241
|
+
totalCostUsd: summary.totals.costUsd,
|
|
242
|
+
totalDurationMs: summary.totals.durationMs,
|
|
243
|
+
averageSessionDurationMs:
|
|
244
|
+
summary.totals.sessions > 0 ? summary.totals.durationMs / summary.totals.sessions : 0,
|
|
245
|
+
longestSessionEstimateMs: timelinePoints.reduce((max, entry) => {
|
|
246
|
+
if (entry.sessions <= 0) return max;
|
|
247
|
+
return Math.max(max, entry.durationMs / entry.sessions);
|
|
248
|
+
}, 0),
|
|
249
|
+
activeDays,
|
|
250
|
+
dateSpanDays: spanDays,
|
|
251
|
+
dailyAverageCostUsd: spanDays > 0 ? summary.totals.costUsd / spanDays : 0,
|
|
252
|
+
mostExpensiveDay,
|
|
253
|
+
};
|
|
254
|
+
}, [dateFrom, dateTo, summary, timelinePoints]);
|
|
255
|
+
|
|
256
|
+
const agentBreakdown = useMemo<AgentBreakdown[]>(() => {
|
|
257
|
+
if (!summary) return [];
|
|
258
|
+
|
|
259
|
+
return SESSION_SOURCES.map((source) => ({
|
|
260
|
+
source,
|
|
261
|
+
label: SOURCE_LABELS[source],
|
|
262
|
+
sessions: summary.byAgent[source].sessions,
|
|
263
|
+
tokens: tokenTotal(summary.byAgent[source].tokens),
|
|
264
|
+
costUsd: summary.byAgent[source].costUsd,
|
|
265
|
+
})).filter((entry) => entry.sessions > 0 || entry.tokens > 0 || entry.costUsd > 0);
|
|
266
|
+
}, [summary]);
|
|
267
|
+
|
|
268
|
+
const modelBreakdown = useMemo<ModelBreakdown[]>(() => {
|
|
269
|
+
if (!summary) return [];
|
|
270
|
+
|
|
271
|
+
return summary.byModel
|
|
272
|
+
.map((entry) => ({
|
|
273
|
+
model: entry.model,
|
|
274
|
+
sessions: entry.sessions,
|
|
275
|
+
tokens: tokenTotal(entry.tokens),
|
|
276
|
+
costUsd: entry.costUsd,
|
|
277
|
+
}))
|
|
278
|
+
.filter((entry) => entry.sessions > 0 || entry.tokens > 0 || entry.costUsd > 0)
|
|
279
|
+
.sort((left, right) => {
|
|
280
|
+
if (right.tokens !== left.tokens) return right.tokens - left.tokens;
|
|
281
|
+
if (right.sessions !== left.sessions) return right.sessions - left.sessions;
|
|
282
|
+
return right.costUsd - left.costUsd;
|
|
283
|
+
});
|
|
284
|
+
}, [summary]);
|
|
285
|
+
|
|
286
|
+
const topRepos = useMemo(
|
|
287
|
+
() =>
|
|
288
|
+
(summary?.topRepos ?? [])
|
|
289
|
+
.map((entry) => ({
|
|
290
|
+
repo: entry.repo,
|
|
291
|
+
sessions: entry.sessions,
|
|
292
|
+
costUsd: entry.costUsd,
|
|
293
|
+
}))
|
|
294
|
+
.slice(0, 8),
|
|
295
|
+
[summary],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
dateFrom,
|
|
300
|
+
dateTo,
|
|
301
|
+
selectedRange,
|
|
302
|
+
setSelectedRange,
|
|
303
|
+
rangeOptions,
|
|
304
|
+
dailyAgentTokensByDate,
|
|
305
|
+
summary,
|
|
306
|
+
timeline: timelinePoints,
|
|
307
|
+
loading,
|
|
308
|
+
error,
|
|
309
|
+
refresh,
|
|
310
|
+
totals,
|
|
311
|
+
agentBreakdown,
|
|
312
|
+
modelBreakdown,
|
|
313
|
+
topRepos,
|
|
314
|
+
};
|
|
315
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Electroview } from "electrobun/view";
|
|
2
|
+
import type { AIStatsRPC } from "@shared/types";
|
|
3
|
+
|
|
4
|
+
type BunRequests = AIStatsRPC["bun"]["requests"];
|
|
5
|
+
type WrappedRPCRequests = Pick<BunRequests, "getDashboardSummary" | "getDailyTimeline">;
|
|
6
|
+
export type RPCRequestName = keyof WrappedRPCRequests;
|
|
7
|
+
export type RPCRequestParams<K extends RPCRequestName> = WrappedRPCRequests[K]["params"];
|
|
8
|
+
export type RPCRequestResponse<K extends RPCRequestName> = WrappedRPCRequests[K]["response"];
|
|
9
|
+
|
|
10
|
+
export const electroview = Electroview.defineRPC<AIStatsRPC>({
|
|
11
|
+
handlers: {},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Instantiate Electroview to connect the WebSocket transport.
|
|
15
|
+
// Without this, the RPC stays on a stub transport (no `send` method).
|
|
16
|
+
new Electroview({ rpc: electroview });
|
|
17
|
+
|
|
18
|
+
export const rpcRequest = <K extends RPCRequestName>(
|
|
19
|
+
method: K,
|
|
20
|
+
params: RPCRequestParams<K>,
|
|
21
|
+
): Promise<RPCRequestResponse<K>> => {
|
|
22
|
+
const requestFn = electroview.request[method] as (
|
|
23
|
+
input: RPCRequestParams<K>,
|
|
24
|
+
) => Promise<RPCRequestResponse<K>>;
|
|
25
|
+
|
|
26
|
+
return requestFn(params);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const useRPC = () => electroview;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
:root,
|
|
4
|
+
:root[data-theme="dark"] {
|
|
5
|
+
color-scheme: dark;
|
|
6
|
+
--surface-0: #020617;
|
|
7
|
+
--surface-1: #091126;
|
|
8
|
+
--surface-2: #101a34;
|
|
9
|
+
--text-primary: #f8fbff;
|
|
10
|
+
--text-secondary: #c8d2e7;
|
|
11
|
+
--text-muted: #8ea2c2;
|
|
12
|
+
--border-soft: #8ba3c933;
|
|
13
|
+
--border-subtle: #c5d4ec2e;
|
|
14
|
+
--border-strong: #8bd5ff99;
|
|
15
|
+
--accent-bg: #0d2449;
|
|
16
|
+
--accent-border: #63c7ff;
|
|
17
|
+
--accent-text: #a7e6ff;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
:root[data-theme="light"] {
|
|
21
|
+
color-scheme: light;
|
|
22
|
+
--surface-0: #eaf3ff;
|
|
23
|
+
--surface-1: #f8fbff;
|
|
24
|
+
--surface-2: #e5f1ff;
|
|
25
|
+
--text-primary: #101828;
|
|
26
|
+
--text-secondary: #334155;
|
|
27
|
+
--text-muted: #5b6b85;
|
|
28
|
+
--border-soft: #7a8ba81f;
|
|
29
|
+
--border-subtle: #4e6b9536;
|
|
30
|
+
--border-strong: #2563eb99;
|
|
31
|
+
--accent-bg: #cbe7ff;
|
|
32
|
+
--accent-border: #1d4ed8;
|
|
33
|
+
--accent-text: #15387f;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@media (prefers-color-scheme: light) {
|
|
37
|
+
:root[data-theme="system"] {
|
|
38
|
+
color-scheme: light;
|
|
39
|
+
--surface-0: #eaf3ff;
|
|
40
|
+
--surface-1: #f8fbff;
|
|
41
|
+
--surface-2: #e5f1ff;
|
|
42
|
+
--text-primary: #101828;
|
|
43
|
+
--text-secondary: #334155;
|
|
44
|
+
--text-muted: #5b6b85;
|
|
45
|
+
--border-soft: #7a8ba81f;
|
|
46
|
+
--border-subtle: #4e6b9536;
|
|
47
|
+
--border-strong: #2563eb99;
|
|
48
|
+
--accent-bg: #cbe7ff;
|
|
49
|
+
--accent-border: #1d4ed8;
|
|
50
|
+
--accent-text: #15387f;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
* {
|
|
55
|
+
box-sizing: border-box;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
html,
|
|
59
|
+
body,
|
|
60
|
+
#root {
|
|
61
|
+
height: 100%;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
body {
|
|
65
|
+
margin: 0;
|
|
66
|
+
font-family: "Space Grotesk", "Avenir Next", "Segoe UI", sans-serif;
|
|
67
|
+
background:
|
|
68
|
+
radial-gradient(90rem 48rem at 92% -8%, rgba(59, 130, 246, 0.25) 0%, transparent 62%),
|
|
69
|
+
radial-gradient(75rem 42rem at -8% 108%, rgba(20, 184, 166, 0.28) 0%, transparent 55%),
|
|
70
|
+
var(--surface-0);
|
|
71
|
+
color: var(--text-primary);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.wrapped-scroll {
|
|
75
|
+
height: 100%;
|
|
76
|
+
overflow-y: auto;
|
|
77
|
+
scroll-snap-type: y mandatory;
|
|
78
|
+
scroll-behavior: smooth;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.wrapped-card {
|
|
82
|
+
position: relative;
|
|
83
|
+
min-height: calc(100vh - 7rem);
|
|
84
|
+
scroll-snap-align: start;
|
|
85
|
+
border-radius: 2rem;
|
|
86
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
87
|
+
backdrop-filter: blur(18px);
|
|
88
|
+
padding: 1.5rem;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
justify-content: center;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.wrapped-card::after {
|
|
96
|
+
content: "";
|
|
97
|
+
position: absolute;
|
|
98
|
+
inset: 0;
|
|
99
|
+
pointer-events: none;
|
|
100
|
+
border-radius: inherit;
|
|
101
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.wrapped-card-hero {
|
|
105
|
+
background:
|
|
106
|
+
linear-gradient(135deg, rgba(38, 21, 91, 0.86), rgba(13, 59, 141, 0.8) 54%, rgba(13, 148, 136, 0.7)),
|
|
107
|
+
rgba(2, 6, 23, 0.78);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.wrapped-card-time {
|
|
111
|
+
background: linear-gradient(140deg, rgba(14, 36, 87, 0.78), rgba(15, 70, 124, 0.68), rgba(14, 165, 233, 0.38));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.wrapped-card-models {
|
|
115
|
+
background: linear-gradient(140deg, rgba(22, 36, 86, 0.8), rgba(10, 73, 107, 0.72), rgba(34, 197, 94, 0.22));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.wrapped-card-agents {
|
|
119
|
+
background: linear-gradient(142deg, rgba(10, 45, 85, 0.8), rgba(8, 80, 105, 0.72), rgba(56, 189, 248, 0.34));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.wrapped-card-activity {
|
|
123
|
+
background: linear-gradient(145deg, rgba(4, 32, 62, 0.85), rgba(2, 75, 82, 0.72), rgba(45, 212, 191, 0.35));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.wrapped-card-cost {
|
|
127
|
+
background: linear-gradient(145deg, rgba(12, 37, 89, 0.82), rgba(7, 75, 121, 0.7), rgba(56, 189, 248, 0.32));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.wrapped-card-repos {
|
|
131
|
+
background: linear-gradient(145deg, rgba(10, 45, 77, 0.84), rgba(11, 81, 110, 0.72), rgba(20, 184, 166, 0.34));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.wrapped-card-loading {
|
|
135
|
+
background: linear-gradient(145deg, rgba(2, 6, 23, 0.88), rgba(13, 30, 60, 0.78));
|
|
136
|
+
align-items: center;
|
|
137
|
+
text-align: center;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.wrapped-kicker {
|
|
141
|
+
margin: 0;
|
|
142
|
+
font-size: 0.72rem;
|
|
143
|
+
letter-spacing: 0.22em;
|
|
144
|
+
text-transform: uppercase;
|
|
145
|
+
color: rgba(186, 230, 253, 0.92);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.wrapped-title {
|
|
149
|
+
margin: 0.35rem 0 0;
|
|
150
|
+
font-size: clamp(1.8rem, 4vw, 3.1rem);
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
line-height: 1.05;
|
|
153
|
+
letter-spacing: -0.03em;
|
|
154
|
+
color: #fff;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.wrapped-tile {
|
|
158
|
+
border-radius: 1.2rem;
|
|
159
|
+
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
160
|
+
background: rgba(2, 6, 23, 0.37);
|
|
161
|
+
padding: 1rem;
|
|
162
|
+
backdrop-filter: blur(8px);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.wrapped-label {
|
|
166
|
+
margin: 0;
|
|
167
|
+
font-size: 0.68rem;
|
|
168
|
+
letter-spacing: 0.18em;
|
|
169
|
+
text-transform: uppercase;
|
|
170
|
+
color: rgba(203, 213, 225, 0.92);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.wrapped-button {
|
|
174
|
+
height: 2.5rem;
|
|
175
|
+
border-radius: 9999px;
|
|
176
|
+
border: 1px solid rgba(125, 211, 252, 0.6);
|
|
177
|
+
padding: 0 1rem;
|
|
178
|
+
background: rgba(56, 189, 248, 0.18);
|
|
179
|
+
color: #f8fafc;
|
|
180
|
+
font-size: 0.82rem;
|
|
181
|
+
font-weight: 600;
|
|
182
|
+
transition: all 0.2s ease;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.wrapped-button:hover {
|
|
186
|
+
background: rgba(56, 189, 248, 0.28);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@media (max-width: 768px) {
|
|
190
|
+
.wrapped-card {
|
|
191
|
+
min-height: calc(100vh - 6.5rem);
|
|
192
|
+
border-radius: 1.5rem;
|
|
193
|
+
padding: 1.1rem;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>AI Wrapped</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./index.ts"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import "./index.css";
|
|
4
|
+
import App from "./App";
|
|
5
|
+
import { electroview } from "./hooks/useRPC";
|
|
6
|
+
|
|
7
|
+
void electroview;
|
|
8
|
+
|
|
9
|
+
const container = document.getElementById("root");
|
|
10
|
+
if (container) {
|
|
11
|
+
createRoot(container).render(createElement(App));
|
|
12
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionSource } from "@shared/schema";
|
|
2
|
+
|
|
3
|
+
export const SOURCE_LABELS: Record<SessionSource, string> = {
|
|
4
|
+
claude: "Claude Code",
|
|
5
|
+
codex: "Codex",
|
|
6
|
+
gemini: "Gemini",
|
|
7
|
+
opencode: "OpenCode",
|
|
8
|
+
droid: "Droid",
|
|
9
|
+
copilot: "Copilot",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const SOURCE_COLORS: Record<SessionSource, string> = {
|
|
13
|
+
claude: "#f59e0b",
|
|
14
|
+
codex: "#14b8a6",
|
|
15
|
+
gemini: "#60a5fa",
|
|
16
|
+
opencode: "#f97316",
|
|
17
|
+
droid: "#34d399",
|
|
18
|
+
copilot: "#f43f5e",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const CHART_COLORS = [
|
|
22
|
+
"#7dd3fc",
|
|
23
|
+
"#86efac",
|
|
24
|
+
"#fca5a5",
|
|
25
|
+
"#fcd34d",
|
|
26
|
+
"#c4b5fd",
|
|
27
|
+
"#fdba74",
|
|
28
|
+
"#67e8f9",
|
|
29
|
+
"#f9a8d4",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_PAGE_SIZE = 20;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const formatNumber = (value: number): string =>
|
|
2
|
+
new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
|
|
3
|
+
|
|
4
|
+
export const formatTokens = (value: number): string => {
|
|
5
|
+
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
|
|
6
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
7
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
|
8
|
+
return formatNumber(value);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const formatUsd = (value: number | null | undefined): string =>
|
|
12
|
+
new Intl.NumberFormat("en-US", {
|
|
13
|
+
style: "currency",
|
|
14
|
+
currency: "USD",
|
|
15
|
+
minimumFractionDigits: 2,
|
|
16
|
+
maximumFractionDigits: 4,
|
|
17
|
+
}).format(value ?? 0);
|
|
18
|
+
|
|
19
|
+
export const formatDate = (value: string | null): string => {
|
|
20
|
+
if (!value) return "-";
|
|
21
|
+
const parsed = Date.parse(value);
|
|
22
|
+
if (Number.isNaN(parsed)) return value;
|
|
23
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
24
|
+
month: "short",
|
|
25
|
+
day: "numeric",
|
|
26
|
+
year: "numeric",
|
|
27
|
+
}).format(new Date(parsed));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const formatTime = (value: string | null): string => {
|
|
31
|
+
if (!value) return "-";
|
|
32
|
+
const parsed = Date.parse(value);
|
|
33
|
+
if (Number.isNaN(parsed)) return value;
|
|
34
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
35
|
+
hour: "numeric",
|
|
36
|
+
minute: "2-digit",
|
|
37
|
+
}).format(new Date(parsed));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const formatDateTime = (value: string | null): string => {
|
|
41
|
+
if (!value) return "-";
|
|
42
|
+
const parsed = Date.parse(value);
|
|
43
|
+
if (Number.isNaN(parsed)) return value;
|
|
44
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
45
|
+
month: "short",
|
|
46
|
+
day: "numeric",
|
|
47
|
+
year: "numeric",
|
|
48
|
+
hour: "numeric",
|
|
49
|
+
minute: "2-digit",
|
|
50
|
+
}).format(new Date(parsed));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const formatRelativeTime = (value: string | null): string => {
|
|
54
|
+
if (!value) return "-";
|
|
55
|
+
const parsed = Date.parse(value);
|
|
56
|
+
if (Number.isNaN(parsed)) return value;
|
|
57
|
+
|
|
58
|
+
const diffMs = parsed - Date.now();
|
|
59
|
+
const absMs = Math.abs(diffMs);
|
|
60
|
+
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
61
|
+
|
|
62
|
+
const minute = 60_000;
|
|
63
|
+
const hour = 60 * minute;
|
|
64
|
+
const day = 24 * hour;
|
|
65
|
+
|
|
66
|
+
if (absMs < hour) return rtf.format(Math.round(diffMs / minute), "minute");
|
|
67
|
+
if (absMs < day) return rtf.format(Math.round(diffMs / hour), "hour");
|
|
68
|
+
return rtf.format(Math.round(diffMs / day), "day");
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const formatDuration = (durationMs: number | null): string => {
|
|
72
|
+
if (!durationMs || durationMs < 0) return "-";
|
|
73
|
+
|
|
74
|
+
const totalSeconds = Math.floor(durationMs / 1000);
|
|
75
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
76
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
77
|
+
const seconds = totalSeconds % 60;
|
|
78
|
+
|
|
79
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
80
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
81
|
+
return `${seconds}s`;
|
|
82
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const APP_NAME = "AI Wrapped";
|