@tokscale/cli 1.0.12 → 1.0.13
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/dist/cli.js +47 -5
- package/dist/cli.js.map +1 -1
- package/dist/tui/config/themes.d.ts.map +1 -1
- package/dist/tui/config/themes.js +1 -1
- package/dist/tui/config/themes.js.map +1 -1
- package/dist/wrapped.d.ts +35 -0
- package/dist/wrapped.d.ts.map +1 -0
- package/dist/wrapped.js +569 -0
- package/dist/wrapped.js.map +1 -0
- package/package.json +4 -2
- package/src/cli.ts +55 -5
- package/src/tui/config/themes.ts +1 -1
- package/src/wrapped.ts +673 -0
package/src/wrapped.ts
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
|
|
2
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
parseLocalSourcesAsync,
|
|
8
|
+
finalizeReportAsync,
|
|
9
|
+
finalizeGraphAsync,
|
|
10
|
+
type ParsedMessages,
|
|
11
|
+
} from "./native.js";
|
|
12
|
+
import { PricingFetcher } from "./pricing.js";
|
|
13
|
+
import { syncCursorCache, loadCursorCredentials } from "./cursor.js";
|
|
14
|
+
import type { SourceType } from "./graph-types.js";
|
|
15
|
+
|
|
16
|
+
interface WrappedData {
|
|
17
|
+
year: string;
|
|
18
|
+
firstDay: string;
|
|
19
|
+
totalDays: number;
|
|
20
|
+
activeDays: number;
|
|
21
|
+
totalTokens: number;
|
|
22
|
+
totalCost: number;
|
|
23
|
+
currentStreak: number;
|
|
24
|
+
longestStreak: number;
|
|
25
|
+
topModels: Array<{ name: string; cost: number; tokens: number }>;
|
|
26
|
+
topClients: Array<{ name: string; cost: number; tokens: number }>;
|
|
27
|
+
contributions: Array<{ date: string; level: 0 | 1 | 2 | 3 | 4 }>;
|
|
28
|
+
totalMessages: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WrappedOptions {
|
|
32
|
+
output?: string;
|
|
33
|
+
year?: string;
|
|
34
|
+
sources?: SourceType[];
|
|
35
|
+
short?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SCALE = 2;
|
|
39
|
+
const IMAGE_WIDTH = 1200 * SCALE;
|
|
40
|
+
const IMAGE_HEIGHT = 1200 * SCALE;
|
|
41
|
+
const PADDING = 56 * SCALE;
|
|
42
|
+
|
|
43
|
+
const COLORS = {
|
|
44
|
+
background: "#10121C",
|
|
45
|
+
textPrimary: "#ffffff",
|
|
46
|
+
textSecondary: "#888888",
|
|
47
|
+
textMuted: "#555555",
|
|
48
|
+
accent: "#00B2FF",
|
|
49
|
+
grade0: "#141A25",
|
|
50
|
+
grade1: "#00B2FF44",
|
|
51
|
+
grade2: "#00B2FF88",
|
|
52
|
+
grade3: "#00B2FFCC",
|
|
53
|
+
grade4: "#00B2FF",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
|
|
57
|
+
opencode: "OpenCode",
|
|
58
|
+
claude: "Claude Code",
|
|
59
|
+
codex: "Codex CLI",
|
|
60
|
+
gemini: "Gemini CLI",
|
|
61
|
+
cursor: "Cursor IDE",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ASSETS_BASE_URL = "https://tokscale.ai/assets";
|
|
65
|
+
|
|
66
|
+
const CLIENT_LOGO_URLS: Record<string, string> = {
|
|
67
|
+
"OpenCode": `${ASSETS_BASE_URL}/client-opencode.png`,
|
|
68
|
+
"Claude Code": `${ASSETS_BASE_URL}/client-claude.jpg`,
|
|
69
|
+
"Codex CLI": `${ASSETS_BASE_URL}/client-openai.jpg`,
|
|
70
|
+
"Gemini CLI": `${ASSETS_BASE_URL}/client-gemini.png`,
|
|
71
|
+
"Cursor IDE": `${ASSETS_BASE_URL}/client-cursor.jpg`,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const TOKSCALE_LOGO_SVG_URL = "https://tokscale.ai/tokscale-logo.svg";
|
|
75
|
+
const TOKSCALE_LOGO_PNG_SIZE = 400;
|
|
76
|
+
|
|
77
|
+
function getImageCacheDir(): string {
|
|
78
|
+
return path.join(os.homedir(), ".cache", "tokscale", "images");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getFontCacheDir(): string {
|
|
82
|
+
return path.join(os.homedir(), ".cache", "tokscale", "fonts");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchAndCacheImage(url: string, filename: string): Promise<string> {
|
|
86
|
+
const cacheDir = getImageCacheDir();
|
|
87
|
+
if (!fs.existsSync(cacheDir)) {
|
|
88
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cachedPath = path.join(cacheDir, filename);
|
|
92
|
+
|
|
93
|
+
if (!fs.existsSync(cachedPath)) {
|
|
94
|
+
const response = await fetch(url);
|
|
95
|
+
if (!response.ok) throw new Error(`Failed to fetch ${url}`);
|
|
96
|
+
const buffer = await response.arrayBuffer();
|
|
97
|
+
fs.writeFileSync(cachedPath, Buffer.from(buffer));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return cachedPath;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function fetchSvgAndConvertToPng(svgUrl: string, filename: string, size: number): Promise<string> {
|
|
104
|
+
const cacheDir = getImageCacheDir();
|
|
105
|
+
if (!fs.existsSync(cacheDir)) {
|
|
106
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const cachedPath = path.join(cacheDir, filename);
|
|
110
|
+
|
|
111
|
+
if (!fs.existsSync(cachedPath)) {
|
|
112
|
+
const response = await fetch(svgUrl);
|
|
113
|
+
if (!response.ok) throw new Error(`Failed to fetch ${svgUrl}`);
|
|
114
|
+
const svgText = await response.text();
|
|
115
|
+
|
|
116
|
+
const resvg = new Resvg(svgText, {
|
|
117
|
+
fitTo: { mode: "width", value: size },
|
|
118
|
+
});
|
|
119
|
+
const pngData = resvg.render();
|
|
120
|
+
fs.writeFileSync(cachedPath, pngData.asPng());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return cachedPath;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const FIGTREE_FONTS = [
|
|
127
|
+
{ weight: "400", file: "Figtree-Regular.ttf", url: "https://fonts.gstatic.com/s/figtree/v9/_Xmz-HUzqDCFdgfMsYiV_F7wfS-Bs_d_QF5e.ttf" },
|
|
128
|
+
{ weight: "700", file: "Figtree-Bold.ttf", url: "https://fonts.gstatic.com/s/figtree/v9/_Xmz-HUzqDCFdgfMsYiV_F7wfS-Bs_eYR15e.ttf" },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
let fontsRegistered = false;
|
|
132
|
+
|
|
133
|
+
async function ensureFontsLoaded(): Promise<void> {
|
|
134
|
+
if (fontsRegistered) return;
|
|
135
|
+
|
|
136
|
+
const cacheDir = getFontCacheDir();
|
|
137
|
+
if (!fs.existsSync(cacheDir)) {
|
|
138
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const font of FIGTREE_FONTS) {
|
|
142
|
+
const fontPath = path.join(cacheDir, font.file);
|
|
143
|
+
|
|
144
|
+
if (!fs.existsSync(fontPath)) {
|
|
145
|
+
const response = await fetch(font.url);
|
|
146
|
+
if (!response.ok) continue;
|
|
147
|
+
const buffer = await response.arrayBuffer();
|
|
148
|
+
fs.writeFileSync(fontPath, Buffer.from(buffer));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (fs.existsSync(fontPath)) {
|
|
152
|
+
GlobalFonts.registerFromPath(fontPath, "Figtree");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fontsRegistered = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function loadWrappedData(options: WrappedOptions): Promise<WrappedData> {
|
|
160
|
+
const year = options.year || new Date().getFullYear().toString();
|
|
161
|
+
const sources = options.sources || ["opencode", "claude", "codex", "gemini", "cursor"];
|
|
162
|
+
const localSources = sources.filter(s => s !== "cursor") as ("opencode" | "claude" | "codex" | "gemini")[];
|
|
163
|
+
const includeCursor = sources.includes("cursor");
|
|
164
|
+
|
|
165
|
+
const since = `${year}-01-01`;
|
|
166
|
+
const until = `${year}-12-31`;
|
|
167
|
+
|
|
168
|
+
const pricingFetcher = new PricingFetcher();
|
|
169
|
+
|
|
170
|
+
const phase1Results = await Promise.allSettled([
|
|
171
|
+
pricingFetcher.fetchPricing(),
|
|
172
|
+
includeCursor && loadCursorCredentials() ? syncCursorCache() : Promise.resolve({ synced: false, rows: 0 }),
|
|
173
|
+
localSources.length > 0
|
|
174
|
+
? parseLocalSourcesAsync({ sources: localSources, since, until, year })
|
|
175
|
+
: Promise.resolve({ messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, processingTimeMs: 0 } as ParsedMessages),
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const cursorSync = phase1Results[1].status === "fulfilled"
|
|
179
|
+
? phase1Results[1].value
|
|
180
|
+
: { synced: false, rows: 0 };
|
|
181
|
+
const localMessages = phase1Results[2].status === "fulfilled"
|
|
182
|
+
? phase1Results[2].value
|
|
183
|
+
: null;
|
|
184
|
+
|
|
185
|
+
const emptyMessages: ParsedMessages = {
|
|
186
|
+
messages: [],
|
|
187
|
+
opencodeCount: 0,
|
|
188
|
+
claudeCount: 0,
|
|
189
|
+
codexCount: 0,
|
|
190
|
+
geminiCount: 0,
|
|
191
|
+
processingTimeMs: 0,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const [reportResult, graphResult] = await Promise.allSettled([
|
|
195
|
+
finalizeReportAsync({
|
|
196
|
+
localMessages: localMessages || emptyMessages,
|
|
197
|
+
pricing: pricingFetcher.toPricingEntries(),
|
|
198
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
199
|
+
since,
|
|
200
|
+
until,
|
|
201
|
+
year,
|
|
202
|
+
}),
|
|
203
|
+
finalizeGraphAsync({
|
|
204
|
+
localMessages: localMessages || emptyMessages,
|
|
205
|
+
pricing: pricingFetcher.toPricingEntries(),
|
|
206
|
+
includeCursor: includeCursor && cursorSync.synced,
|
|
207
|
+
since,
|
|
208
|
+
until,
|
|
209
|
+
year,
|
|
210
|
+
}),
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
if (reportResult.status === "rejected") {
|
|
214
|
+
throw new Error(`Failed to generate report: ${reportResult.reason}`);
|
|
215
|
+
}
|
|
216
|
+
if (graphResult.status === "rejected") {
|
|
217
|
+
throw new Error(`Failed to generate graph: ${graphResult.reason}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const report = reportResult.value;
|
|
221
|
+
const graph = graphResult.value;
|
|
222
|
+
|
|
223
|
+
const modelMap = new Map<string, { cost: number; tokens: number }>();
|
|
224
|
+
for (const entry of report.entries) {
|
|
225
|
+
const existing = modelMap.get(entry.model) || { cost: 0, tokens: 0 };
|
|
226
|
+
modelMap.set(entry.model, {
|
|
227
|
+
cost: existing.cost + entry.cost,
|
|
228
|
+
tokens: existing.tokens + entry.input + entry.output + entry.cacheRead + entry.cacheWrite,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const topModels = Array.from(modelMap.entries())
|
|
232
|
+
.map(([name, data]) => ({ name, ...data }))
|
|
233
|
+
.sort((a, b) => b.cost - a.cost)
|
|
234
|
+
.slice(0, 3);
|
|
235
|
+
|
|
236
|
+
const clientMap = new Map<string, { cost: number; tokens: number }>();
|
|
237
|
+
for (const entry of report.entries) {
|
|
238
|
+
const displayName = SOURCE_DISPLAY_NAMES[entry.source] || entry.source;
|
|
239
|
+
const existing = clientMap.get(displayName) || { cost: 0, tokens: 0 };
|
|
240
|
+
clientMap.set(displayName, {
|
|
241
|
+
cost: existing.cost + entry.cost,
|
|
242
|
+
tokens: existing.tokens + entry.input + entry.output + entry.cacheRead + entry.cacheWrite,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const topClients = Array.from(clientMap.entries())
|
|
246
|
+
.map(([name, data]) => ({ name, ...data }))
|
|
247
|
+
.sort((a, b) => b.cost - a.cost)
|
|
248
|
+
.slice(0, 3);
|
|
249
|
+
|
|
250
|
+
const maxCost = Math.max(...graph.contributions.map(c => c.totals.cost), 1);
|
|
251
|
+
const contributions = graph.contributions.map(c => ({
|
|
252
|
+
date: c.date,
|
|
253
|
+
level: calculateIntensity(c.totals.cost, maxCost),
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
const sortedDates = contributions.map(c => c.date).filter(d => d.startsWith(year)).sort();
|
|
257
|
+
const { currentStreak, longestStreak } = calculateStreaks(sortedDates);
|
|
258
|
+
|
|
259
|
+
const firstDay = sortedDates.length > 0 ? sortedDates[0] : `${year}-01-01`;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
year,
|
|
263
|
+
firstDay,
|
|
264
|
+
totalDays: graph.summary.totalDays,
|
|
265
|
+
activeDays: graph.summary.activeDays,
|
|
266
|
+
totalTokens: graph.summary.totalTokens,
|
|
267
|
+
totalCost: graph.summary.totalCost,
|
|
268
|
+
currentStreak,
|
|
269
|
+
longestStreak,
|
|
270
|
+
topModels,
|
|
271
|
+
topClients,
|
|
272
|
+
contributions,
|
|
273
|
+
totalMessages: report.totalMessages,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function calculateIntensity(cost: number, maxCost: number): 0 | 1 | 2 | 3 | 4 {
|
|
278
|
+
if (cost === 0 || maxCost === 0) return 0;
|
|
279
|
+
const ratio = cost / maxCost;
|
|
280
|
+
if (ratio >= 0.75) return 4;
|
|
281
|
+
if (ratio >= 0.5) return 3;
|
|
282
|
+
if (ratio >= 0.25) return 2;
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function calculateStreaks(sortedDates: string[]): { currentStreak: number; longestStreak: number } {
|
|
287
|
+
if (sortedDates.length === 0) return { currentStreak: 0, longestStreak: 0 };
|
|
288
|
+
|
|
289
|
+
const todayStr = new Date().toISOString().split("T")[0];
|
|
290
|
+
let currentStreak = 0;
|
|
291
|
+
let longestStreak = 0;
|
|
292
|
+
let streak = 1;
|
|
293
|
+
|
|
294
|
+
for (let i = sortedDates.length - 1; i >= 0; i--) {
|
|
295
|
+
if (i === sortedDates.length - 1) {
|
|
296
|
+
const daysDiff = dateDiffDays(sortedDates[i], todayStr);
|
|
297
|
+
if (daysDiff <= 1) {
|
|
298
|
+
currentStreak = 1;
|
|
299
|
+
} else {
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
const daysDiff = dateDiffDays(sortedDates[i], sortedDates[i + 1]);
|
|
304
|
+
if (daysDiff === 1) {
|
|
305
|
+
currentStreak++;
|
|
306
|
+
} else {
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (let i = 1; i < sortedDates.length; i++) {
|
|
313
|
+
const daysDiff = dateDiffDays(sortedDates[i - 1], sortedDates[i]);
|
|
314
|
+
if (daysDiff === 1) {
|
|
315
|
+
streak++;
|
|
316
|
+
} else {
|
|
317
|
+
longestStreak = Math.max(longestStreak, streak);
|
|
318
|
+
streak = 1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
longestStreak = Math.max(longestStreak, streak);
|
|
322
|
+
|
|
323
|
+
return { currentStreak, longestStreak };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function dateDiffDays(date1: string, date2: string): number {
|
|
327
|
+
const d1 = new Date(date1 + "T00:00:00Z");
|
|
328
|
+
const d2 = new Date(date2 + "T00:00:00Z");
|
|
329
|
+
return Math.abs(Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24)));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function formatTokens(tokens: number): string {
|
|
333
|
+
if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(2)}B`;
|
|
334
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M`;
|
|
335
|
+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`;
|
|
336
|
+
return tokens.toString();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function formatCost(cost: number): string {
|
|
340
|
+
if (cost >= 1000) return `$${(cost / 1000).toFixed(2)}K`;
|
|
341
|
+
return `$${cost.toFixed(2)}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
|
345
|
+
"claude-sonnet-4-20250514": "Claude Sonnet 4",
|
|
346
|
+
"claude-3-5-sonnet-20241022": "Claude 3.5 Sonnet",
|
|
347
|
+
"claude-3-5-sonnet-20240620": "Claude 3.5 Sonnet",
|
|
348
|
+
"claude-3-opus-20240229": "Claude 3 Opus",
|
|
349
|
+
"claude-3-haiku-20240307": "Claude 3 Haiku",
|
|
350
|
+
"gpt-4o": "GPT-4o",
|
|
351
|
+
"gpt-4o-mini": "GPT-4o Mini",
|
|
352
|
+
"gpt-4-turbo": "GPT-4 Turbo",
|
|
353
|
+
"o1": "o1",
|
|
354
|
+
"o1-mini": "o1 Mini",
|
|
355
|
+
"o1-preview": "o1 Preview",
|
|
356
|
+
"o3-mini": "o3 Mini",
|
|
357
|
+
"gemini-2.5-pro": "Gemini 2.5 Pro",
|
|
358
|
+
"gemini-2.5-flash": "Gemini 2.5 Flash",
|
|
359
|
+
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
|
360
|
+
"gemini-1.5-pro": "Gemini 1.5 Pro",
|
|
361
|
+
"gemini-1.5-flash": "Gemini 1.5 Flash",
|
|
362
|
+
"grok-3": "Grok 3",
|
|
363
|
+
"grok-3-mini": "Grok 3 Mini",
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
function formatModelName(model: string): string {
|
|
367
|
+
if (MODEL_DISPLAY_NAMES[model]) return MODEL_DISPLAY_NAMES[model];
|
|
368
|
+
|
|
369
|
+
const suffixMatch = model.match(/[-_](high|medium|low)$/i);
|
|
370
|
+
const suffix = suffixMatch ? ` ${suffixMatch[1].charAt(0).toUpperCase()}${suffixMatch[1].slice(1).toLowerCase()}` : "";
|
|
371
|
+
|
|
372
|
+
const cleaned = model
|
|
373
|
+
.replace(/-20\d{6,8}(-\d+)?$/, "")
|
|
374
|
+
.replace(/-\d{8}$/, "")
|
|
375
|
+
.replace(/:[-\w]+$/, "")
|
|
376
|
+
.replace(/[-_](high|medium|low)$/i, "")
|
|
377
|
+
.replace(/[-_]thinking$/i, "");
|
|
378
|
+
|
|
379
|
+
if (/claude[-_]?opus[-_]?4[-_.]?5/i.test(cleaned)) return `Claude Opus 4.5${suffix}`;
|
|
380
|
+
if (/claude[-_]?4[-_]?opus/i.test(cleaned)) return `Claude 4 Opus${suffix}`;
|
|
381
|
+
if (/claude[-_]?opus[-_]?4/i.test(cleaned)) return `Claude Opus 4${suffix}`;
|
|
382
|
+
if (/claude[-_]?sonnet[-_]?4[-_.]?5/i.test(cleaned)) return `Claude Sonnet 4.5${suffix}`;
|
|
383
|
+
if (/claude[-_]?4[-_]?sonnet/i.test(cleaned)) return `Claude 4 Sonnet${suffix}`;
|
|
384
|
+
if (/claude[-_]?sonnet[-_]?4/i.test(cleaned)) return `Claude Sonnet 4${suffix}`;
|
|
385
|
+
if (/claude[-_]?haiku[-_]?4[-_.]?5/i.test(cleaned)) return `Claude Haiku 4.5${suffix}`;
|
|
386
|
+
if (/claude[-_]?4[-_]?haiku/i.test(cleaned)) return `Claude 4 Haiku${suffix}`;
|
|
387
|
+
if (/claude[-_]?haiku[-_]?4/i.test(cleaned)) return `Claude Haiku 4${suffix}`;
|
|
388
|
+
if (/claude[-_]?3[-_.]?7[-_]?sonnet/i.test(cleaned)) return `Claude 3.7 Sonnet${suffix}`;
|
|
389
|
+
if (/claude[-_]?3[-_.]?5[-_]?sonnet/i.test(cleaned)) return `Claude 3.5 Sonnet${suffix}`;
|
|
390
|
+
if (/claude[-_]?3[-_.]?5[-_]?haiku/i.test(cleaned)) return `Claude 3.5 Haiku${suffix}`;
|
|
391
|
+
if (/claude[-_]?3[-_]?opus/i.test(cleaned)) return `Claude 3 Opus${suffix}`;
|
|
392
|
+
if (/claude[-_]?3[-_]?sonnet/i.test(cleaned)) return `Claude 3 Sonnet${suffix}`;
|
|
393
|
+
if (/claude[-_]?3[-_]?haiku/i.test(cleaned)) return `Claude 3 Haiku${suffix}`;
|
|
394
|
+
if (/gpt[-_]?5[-_.]?1/i.test(cleaned)) return `GPT-5.1${suffix}`;
|
|
395
|
+
if (/gpt[-_]?5/i.test(cleaned)) return `GPT-5${suffix}`;
|
|
396
|
+
if (/gpt[-_]?4[-_]?o[-_]?mini/i.test(cleaned)) return `GPT-4o Mini${suffix}`;
|
|
397
|
+
if (/gpt[-_]?4[-_]?o/i.test(cleaned)) return `GPT-4o${suffix}`;
|
|
398
|
+
if (/gpt[-_]?4[-_]?turbo/i.test(cleaned)) return `GPT-4 Turbo${suffix}`;
|
|
399
|
+
if (/gpt[-_]?4/i.test(cleaned)) return `GPT-4${suffix}`;
|
|
400
|
+
if (/^o1[-_]?mini/i.test(cleaned)) return `o1 Mini${suffix}`;
|
|
401
|
+
if (/^o1[-_]?preview/i.test(cleaned)) return `o1 Preview${suffix}`;
|
|
402
|
+
if (/^o3[-_]?mini/i.test(cleaned)) return `o3 Mini${suffix}`;
|
|
403
|
+
if (/^o1$/i.test(cleaned)) return `o1${suffix}`;
|
|
404
|
+
if (/^o3$/i.test(cleaned)) return `o3${suffix}`;
|
|
405
|
+
if (/gemini[-_]?3[-_]?pro/i.test(cleaned)) return `Gemini 3 Pro${suffix}`;
|
|
406
|
+
if (/gemini[-_]?3[-_]?flash/i.test(cleaned)) return `Gemini 3 Flash${suffix}`;
|
|
407
|
+
if (/gemini[-_]?2[-_.]?5[-_]?pro/i.test(cleaned)) return `Gemini 2.5 Pro${suffix}`;
|
|
408
|
+
if (/gemini[-_]?2[-_.]?5[-_]?flash/i.test(cleaned)) return `Gemini 2.5 Flash${suffix}`;
|
|
409
|
+
if (/gemini[-_]?2[-_.]?0[-_]?flash/i.test(cleaned)) return `Gemini 2.0 Flash${suffix}`;
|
|
410
|
+
if (/gemini[-_]?1[-_.]?5[-_]?pro/i.test(cleaned)) return `Gemini 1.5 Pro${suffix}`;
|
|
411
|
+
if (/gemini[-_]?1[-_.]?5[-_]?flash/i.test(cleaned)) return `Gemini 1.5 Flash${suffix}`;
|
|
412
|
+
if (/grok[-_]?3[-_]?mini/i.test(cleaned)) return `Grok Code 3 Mini${suffix}`;
|
|
413
|
+
if (/grok[-_]?3/i.test(cleaned)) return `Grok Code 3${suffix}`;
|
|
414
|
+
if (/grok/i.test(cleaned)) return `Grok Code${suffix}`;
|
|
415
|
+
if (/deepseek[-_]?v3/i.test(cleaned)) return `DeepSeek V3${suffix}`;
|
|
416
|
+
if (/deepseek[-_]?r1/i.test(cleaned)) return `DeepSeek R1${suffix}`;
|
|
417
|
+
if (/deepseek/i.test(cleaned)) return `DeepSeek${suffix}`;
|
|
418
|
+
|
|
419
|
+
const baseName = cleaned
|
|
420
|
+
.replace(/^claude[-_]/i, "Claude ")
|
|
421
|
+
.replace(/^gpt[-_]/i, "GPT-")
|
|
422
|
+
.replace(/^gemini[-_]/i, "Gemini ")
|
|
423
|
+
.replace(/^grok[-_]/i, "Grok Code ")
|
|
424
|
+
.split(/[-_]/)
|
|
425
|
+
.filter(Boolean)
|
|
426
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
427
|
+
.join(" ")
|
|
428
|
+
.trim();
|
|
429
|
+
|
|
430
|
+
return `${baseName}${suffix}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function drawRoundedRect(
|
|
434
|
+
ctx: ReturnType<ReturnType<typeof createCanvas>["getContext"]>,
|
|
435
|
+
x: number,
|
|
436
|
+
y: number,
|
|
437
|
+
width: number,
|
|
438
|
+
height: number,
|
|
439
|
+
radius: number
|
|
440
|
+
) {
|
|
441
|
+
ctx.beginPath();
|
|
442
|
+
ctx.moveTo(x + radius, y);
|
|
443
|
+
ctx.lineTo(x + width - radius, y);
|
|
444
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
445
|
+
ctx.lineTo(x + width, y + height - radius);
|
|
446
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
447
|
+
ctx.lineTo(x + radius, y + height);
|
|
448
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
449
|
+
ctx.lineTo(x, y + radius);
|
|
450
|
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
451
|
+
ctx.closePath();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function drawContributionGraph(
|
|
455
|
+
ctx: ReturnType<ReturnType<typeof createCanvas>["getContext"]>,
|
|
456
|
+
data: WrappedData,
|
|
457
|
+
x: number,
|
|
458
|
+
y: number,
|
|
459
|
+
width: number,
|
|
460
|
+
height: number
|
|
461
|
+
) {
|
|
462
|
+
const year = parseInt(data.year);
|
|
463
|
+
const startDate = new Date(year, 0, 1);
|
|
464
|
+
const endDate = new Date(year, 11, 31);
|
|
465
|
+
|
|
466
|
+
const contribMap = new Map(data.contributions.map(c => [c.date, c.level]));
|
|
467
|
+
|
|
468
|
+
const DAYS_PER_ROW = 14;
|
|
469
|
+
const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
470
|
+
const totalRows = Math.ceil(totalDays / DAYS_PER_ROW);
|
|
471
|
+
|
|
472
|
+
const cellSize = Math.min(
|
|
473
|
+
Math.floor(height / totalRows),
|
|
474
|
+
Math.floor(width / DAYS_PER_ROW)
|
|
475
|
+
);
|
|
476
|
+
const dotRadius = (cellSize - 2 * SCALE) / 2;
|
|
477
|
+
|
|
478
|
+
const graphWidth = DAYS_PER_ROW * cellSize;
|
|
479
|
+
const graphHeight = totalRows * cellSize;
|
|
480
|
+
const offsetX = x + (width - graphWidth) / 2;
|
|
481
|
+
const offsetY = y;
|
|
482
|
+
|
|
483
|
+
const gradeColors = [COLORS.grade0, COLORS.grade1, COLORS.grade2, COLORS.grade3, COLORS.grade4];
|
|
484
|
+
|
|
485
|
+
const currentDate = new Date(startDate);
|
|
486
|
+
let dayIndex = 0;
|
|
487
|
+
|
|
488
|
+
while (currentDate <= endDate) {
|
|
489
|
+
const dateStr = currentDate.toISOString().split("T")[0];
|
|
490
|
+
const level = contribMap.get(dateStr) || 0;
|
|
491
|
+
|
|
492
|
+
const col = dayIndex % DAYS_PER_ROW;
|
|
493
|
+
const row = Math.floor(dayIndex / DAYS_PER_ROW);
|
|
494
|
+
|
|
495
|
+
const centerX = offsetX + col * cellSize + cellSize / 2;
|
|
496
|
+
const centerY = offsetY + row * cellSize + cellSize / 2;
|
|
497
|
+
|
|
498
|
+
ctx.beginPath();
|
|
499
|
+
ctx.arc(centerX, centerY, dotRadius, 0, Math.PI * 2);
|
|
500
|
+
ctx.fillStyle = gradeColors[level];
|
|
501
|
+
ctx.fill();
|
|
502
|
+
|
|
503
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
504
|
+
dayIndex++;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function drawStat(
|
|
509
|
+
ctx: ReturnType<ReturnType<typeof createCanvas>["getContext"]>,
|
|
510
|
+
x: number,
|
|
511
|
+
y: number,
|
|
512
|
+
label: string,
|
|
513
|
+
value: string
|
|
514
|
+
) {
|
|
515
|
+
ctx.fillStyle = COLORS.textSecondary;
|
|
516
|
+
ctx.font = `${18 * SCALE}px Figtree, sans-serif`;
|
|
517
|
+
ctx.fillText(label, x, y);
|
|
518
|
+
|
|
519
|
+
ctx.fillStyle = COLORS.textPrimary;
|
|
520
|
+
ctx.font = `bold ${36 * SCALE}px Figtree, sans-serif`;
|
|
521
|
+
ctx.fillText(value, x, y + 48 * SCALE);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function formatDate(dateStr: string): string {
|
|
525
|
+
const date = new Date(dateStr + "T00:00:00");
|
|
526
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function generateWrappedImage(data: WrappedData, options: { short?: boolean } = {}): Promise<Buffer> {
|
|
530
|
+
await ensureFontsLoaded();
|
|
531
|
+
|
|
532
|
+
const canvas = createCanvas(IMAGE_WIDTH, IMAGE_HEIGHT);
|
|
533
|
+
const ctx = canvas.getContext("2d");
|
|
534
|
+
|
|
535
|
+
ctx.fillStyle = COLORS.background;
|
|
536
|
+
ctx.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
|
537
|
+
|
|
538
|
+
const leftWidth = IMAGE_WIDTH * 0.45;
|
|
539
|
+
const rightWidth = IMAGE_WIDTH * 0.55;
|
|
540
|
+
const rightX = leftWidth;
|
|
541
|
+
|
|
542
|
+
let yPos = PADDING + 24 * SCALE;
|
|
543
|
+
|
|
544
|
+
ctx.fillStyle = COLORS.textSecondary;
|
|
545
|
+
ctx.font = `${24 * SCALE}px Figtree, sans-serif`;
|
|
546
|
+
ctx.fillText(`Tracking since ${formatDate(data.firstDay)}`, PADDING, yPos);
|
|
547
|
+
yPos += 60 * SCALE;
|
|
548
|
+
|
|
549
|
+
ctx.fillStyle = COLORS.textSecondary;
|
|
550
|
+
ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
|
|
551
|
+
ctx.fillText("Total Tokens", PADDING, yPos);
|
|
552
|
+
yPos += 64 * SCALE;
|
|
553
|
+
|
|
554
|
+
ctx.fillStyle = COLORS.grade4;
|
|
555
|
+
ctx.font = `bold ${56 * SCALE}px Figtree, sans-serif`;
|
|
556
|
+
const totalTokensDisplay = options.short
|
|
557
|
+
? formatTokens(data.totalTokens)
|
|
558
|
+
: data.totalTokens.toLocaleString();
|
|
559
|
+
ctx.fillText(totalTokensDisplay, PADDING, yPos);
|
|
560
|
+
yPos += 50 * SCALE + 40 * SCALE;
|
|
561
|
+
|
|
562
|
+
ctx.fillStyle = COLORS.textSecondary;
|
|
563
|
+
ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
|
|
564
|
+
ctx.fillText("Top Models", PADDING, yPos);
|
|
565
|
+
yPos += 48 * SCALE;
|
|
566
|
+
|
|
567
|
+
for (let i = 0; i < data.topModels.length; i++) {
|
|
568
|
+
const model = data.topModels[i];
|
|
569
|
+
ctx.fillStyle = COLORS.textPrimary;
|
|
570
|
+
ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
|
|
571
|
+
ctx.fillText(`${i + 1}`, PADDING, yPos);
|
|
572
|
+
|
|
573
|
+
ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
|
|
574
|
+
ctx.fillText(formatModelName(model.name), PADDING + 40 * SCALE, yPos);
|
|
575
|
+
yPos += 50 * SCALE;
|
|
576
|
+
}
|
|
577
|
+
yPos += 40 * SCALE;
|
|
578
|
+
|
|
579
|
+
ctx.fillStyle = COLORS.textSecondary;
|
|
580
|
+
ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
|
|
581
|
+
ctx.fillText("Top Clients", PADDING, yPos);
|
|
582
|
+
yPos += 48 * SCALE;
|
|
583
|
+
|
|
584
|
+
const logoSize = 32 * SCALE;
|
|
585
|
+
|
|
586
|
+
for (let i = 0; i < data.topClients.length; i++) {
|
|
587
|
+
const client = data.topClients[i];
|
|
588
|
+
ctx.fillStyle = COLORS.textPrimary;
|
|
589
|
+
ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
|
|
590
|
+
ctx.fillText(`${i + 1}`, PADDING, yPos);
|
|
591
|
+
|
|
592
|
+
const logoUrl = CLIENT_LOGO_URLS[client.name];
|
|
593
|
+
if (logoUrl) {
|
|
594
|
+
try {
|
|
595
|
+
const filename = `client-${client.name.toLowerCase().replace(/\s+/g, "-")}@2x.png`;
|
|
596
|
+
const logoPath = await fetchAndCacheImage(logoUrl, filename);
|
|
597
|
+
const logo = await loadImage(logoPath);
|
|
598
|
+
const logoY = yPos - logoSize + 6 * SCALE;
|
|
599
|
+
|
|
600
|
+
const logoX = PADDING + 40 * SCALE;
|
|
601
|
+
const logoRadius = 6 * SCALE;
|
|
602
|
+
|
|
603
|
+
ctx.save();
|
|
604
|
+
drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
|
|
605
|
+
ctx.clip();
|
|
606
|
+
ctx.drawImage(logo, logoX, logoY, logoSize, logoSize);
|
|
607
|
+
ctx.restore();
|
|
608
|
+
|
|
609
|
+
drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
|
|
610
|
+
ctx.strokeStyle = "#141A25";
|
|
611
|
+
ctx.lineWidth = 1 * SCALE;
|
|
612
|
+
ctx.stroke();
|
|
613
|
+
} catch {
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
|
|
618
|
+
ctx.fillText(client.name, PADDING + 40 * SCALE + logoSize + 12 * SCALE, yPos);
|
|
619
|
+
yPos += 50 * SCALE;
|
|
620
|
+
}
|
|
621
|
+
yPos += 40 * SCALE;
|
|
622
|
+
|
|
623
|
+
const statsStartY = yPos;
|
|
624
|
+
const statWidth = (leftWidth - PADDING * 2) / 2;
|
|
625
|
+
|
|
626
|
+
drawStat(ctx, PADDING, statsStartY, "Messages", data.totalMessages.toLocaleString());
|
|
627
|
+
drawStat(ctx, PADDING + statWidth, statsStartY, "Active Days", `${data.activeDays}`);
|
|
628
|
+
|
|
629
|
+
drawStat(ctx, PADDING, statsStartY + 100 * SCALE, "Cost", formatCost(data.totalCost));
|
|
630
|
+
drawStat(ctx, PADDING + statWidth, statsStartY + 100 * SCALE, "Streak", `${data.longestStreak}d`);
|
|
631
|
+
|
|
632
|
+
const footerBottomY = IMAGE_HEIGHT - PADDING;
|
|
633
|
+
const tokscaleLogoHeight = 72 * SCALE;
|
|
634
|
+
|
|
635
|
+
drawContributionGraph(
|
|
636
|
+
ctx,
|
|
637
|
+
data,
|
|
638
|
+
rightX,
|
|
639
|
+
PADDING,
|
|
640
|
+
rightWidth - PADDING,
|
|
641
|
+
IMAGE_HEIGHT - PADDING * 2
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
const logoPath = await fetchSvgAndConvertToPng(TOKSCALE_LOGO_SVG_URL, "tokscale-logo@2x.png", TOKSCALE_LOGO_PNG_SIZE * SCALE);
|
|
646
|
+
const tokscaleLogo = await loadImage(logoPath);
|
|
647
|
+
const logoWidth = (tokscaleLogo.width / tokscaleLogo.height) * tokscaleLogoHeight;
|
|
648
|
+
|
|
649
|
+
ctx.fillStyle = COLORS.textSecondary;
|
|
650
|
+
ctx.font = `${18 * SCALE}px Figtree, sans-serif`;
|
|
651
|
+
ctx.fillText("github.com/junhoyeo/tokscale", PADDING, footerBottomY);
|
|
652
|
+
|
|
653
|
+
const logoY = footerBottomY - 18 * SCALE - 16 * SCALE - tokscaleLogoHeight;
|
|
654
|
+
ctx.drawImage(tokscaleLogo, PADDING, logoY, logoWidth, tokscaleLogoHeight);
|
|
655
|
+
} catch {
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return canvas.toBuffer("image/png");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export async function generateWrapped(options: WrappedOptions): Promise<string> {
|
|
662
|
+
const data = await loadWrappedData(options);
|
|
663
|
+
const imageBuffer = await generateWrappedImage(data, { short: options.short });
|
|
664
|
+
|
|
665
|
+
const outputPath = options.output || `tokscale-${data.year}-wrapped.png`;
|
|
666
|
+
const absolutePath = path.resolve(outputPath);
|
|
667
|
+
|
|
668
|
+
fs.writeFileSync(absolutePath, imageBuffer);
|
|
669
|
+
|
|
670
|
+
return absolutePath;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export { type WrappedData };
|