@lensin3-npm/cc-wrapped 1.0.0

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.
@@ -0,0 +1,26 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+ workflow_dispatch: # Allows manual trigger
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write # Required for npm trusted publishing (provenance)
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '20'
20
+ registry-url: 'https://registry.npmjs.org'
21
+
22
+ - run: npm install
23
+
24
+ - run: npm publish --provenance --access public
25
+ env:
26
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # cc-wrapped
2
+
3
+ Your year in code, wrapped. Visualize your git history and Claude Code usage in a beautiful, shareable format.
4
+
5
+ ![cc-wrapped](https://img.shields.io/npm/v/@lensin3/cc-wrapped?style=flat-square)
6
+ ![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)
7
+
8
+ ## Features
9
+
10
+ - **Git Stats**: Commits, lines of code, activity by day/hour, most edited files
11
+ - **Claude Code Stats**: Tokens used, messages sent, tool calls, session data
12
+ - **Beautiful HTML Output**: Dark theme, responsive design, ready for screenshots
13
+ - **Flexible Modes**: Full wrapped, tokens-only, or git-only
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ # Run directly with npx (no install needed)
19
+ npx @lensin3/cc-wrapped
20
+
21
+ # Or install globally
22
+ npm install -g @lensin3/cc-wrapped
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```bash
28
+ # Full wrapped (git + Claude Code tokens)
29
+ cc-wrapped
30
+
31
+ # Claude Code tokens only
32
+ cc-wrapped --tokens
33
+
34
+ # Git stats only
35
+ cc-wrapped --git
36
+
37
+ # Specify year
38
+ cc-wrapped --year 2024
39
+
40
+ # Custom output path
41
+ cc-wrapped --output my-wrapped.html
42
+
43
+ # Don't open browser automatically
44
+ cc-wrapped --no-open
45
+ ```
46
+
47
+ ## Options
48
+
49
+ | Flag | Description |
50
+ |------|-------------|
51
+ | `-t, --tokens` | Show Claude Code token usage only |
52
+ | `-g, --git` | Show git stats only (no Claude Code tokens) |
53
+ | `-y, --year <year>` | Year to analyze (default: current year) |
54
+ | `-o, --output <path>` | Output file path (default: ./wrapped.html) |
55
+ | `--no-open` | Don't open the generated HTML in browser |
56
+
57
+ ## Requirements
58
+
59
+ - **Node.js 18+**
60
+ - **Git** (for git stats)
61
+ - **Claude Code** (optional, for token stats - reads from `~/.claude/stats-cache.json`)
62
+
63
+ ## What It Looks Like
64
+
65
+ The generated HTML includes:
66
+
67
+ - **Big Numbers**: Total tokens, messages, commits, lines of code
68
+ - **Monthly Activity**: Commit distribution by month
69
+ - **Day of Week**: Your most productive day
70
+ - **Productive Hours**: When you code the most
71
+ - **Token Breakdown**: Input/output/cache token distribution
72
+ - **Achievements**: Unlocked badges based on your activity
73
+
74
+ ## Sharing
75
+
76
+ 1. Run `cc-wrapped` in your project
77
+ 2. Open the generated HTML
78
+ 3. Take a screenshot or save as PDF (`Cmd+P` → Save as PDF)
79
+ 4. Share on Twitter/LinkedIn!
80
+
81
+ ## License
82
+
83
+ MIT
84
+
85
+ ## Author
86
+
87
+ Built with Claude Code.
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const { generateWrapped } = require('../src/index.js');
5
+
6
+ program
7
+ .name('cc-wrapped')
8
+ .description('Your year in code, wrapped. Visualize your git history and Claude Code usage.')
9
+ .version('1.0.0')
10
+ .option('-t, --tokens', 'Show Claude Code token usage only')
11
+ .option('-g, --git', 'Show git stats only (no Claude Code tokens)')
12
+ .option('-y, --year <year>', 'Year to analyze (default: current year)', new Date().getFullYear().toString())
13
+ .option('-o, --output <path>', 'Output file path (default: ./wrapped.html)')
14
+ .option('--no-open', 'Do not open the generated HTML in browser')
15
+ .parse(process.argv);
16
+
17
+ const options = program.opts();
18
+
19
+ generateWrapped({
20
+ tokensOnly: options.tokens || false,
21
+ gitOnly: options.git || false,
22
+ year: parseInt(options.year),
23
+ outputPath: options.output || './wrapped.html',
24
+ openBrowser: options.open !== false
25
+ }).catch(err => {
26
+ console.error('Error:', err.message);
27
+ process.exit(1);
28
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@lensin3-npm/cc-wrapped",
3
+ "version": "1.0.0",
4
+ "description": "Your year in code, wrapped. Visualize your git history and Claude Code usage in a beautiful shareable format.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "cc-wrapped": "./bin/cc-wrapped.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/cc-wrapped.js"
11
+ },
12
+ "keywords": [
13
+ "git",
14
+ "wrapped",
15
+ "claude",
16
+ "claude-code",
17
+ "year-in-review",
18
+ "developer-tools",
19
+ "cli"
20
+ ],
21
+ "author": "Leonard Paul-Kamara",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "commander": "^12.0.0",
25
+ "open": "^10.0.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/LenSin3/cc-wrapped"
33
+ }
34
+ }
package/src/index.js ADDED
@@ -0,0 +1,763 @@
1
+ const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ // Spinner for progress indication
7
+ class Spinner {
8
+ constructor(message) {
9
+ this.frames = ['ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā '];
10
+ this.message = message;
11
+ this.current = 0;
12
+ this.interval = null;
13
+ }
14
+
15
+ start() {
16
+ process.stdout.write(`${this.frames[0]} ${this.message}`);
17
+ this.interval = setInterval(() => {
18
+ this.current = (this.current + 1) % this.frames.length;
19
+ process.stdout.clearLine?.(0);
20
+ process.stdout.cursorTo?.(0);
21
+ process.stdout.write(`${this.frames[this.current]} ${this.message}`);
22
+ }, 80);
23
+ }
24
+
25
+ succeed(text) {
26
+ clearInterval(this.interval);
27
+ process.stdout.clearLine?.(0);
28
+ process.stdout.cursorTo?.(0);
29
+ console.log(`āœ“ ${text || this.message}`);
30
+ }
31
+
32
+ fail(text) {
33
+ clearInterval(this.interval);
34
+ process.stdout.clearLine?.(0);
35
+ process.stdout.cursorTo?.(0);
36
+ console.log(`āœ— ${text || this.message}`);
37
+ }
38
+ }
39
+
40
+ // Helper to run git commands
41
+ function git(cmd) {
42
+ try {
43
+ return execSync(`git ${cmd}`, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }).trim();
44
+ } catch (e) {
45
+ return '';
46
+ }
47
+ }
48
+
49
+ // Check if we're in a git repo
50
+ function isGitRepo() {
51
+ try {
52
+ execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ // Get git stats for the year
60
+ function getGitStats(year) {
61
+ const since = `${year}-01-01`;
62
+
63
+ // Total commits
64
+ const totalCommits = parseInt(git(`log --since="${since}" --oneline | wc -l`)) || 0;
65
+
66
+ // Lines added/deleted
67
+ const lineStats = git(`log --since="${since}" --format="" --numstat | awk '{added+=$1; deleted+=$2} END {print added, deleted}'`).split(' ');
68
+ const linesAdded = parseInt(lineStats[0]) || 0;
69
+ const linesDeleted = parseInt(lineStats[1]) || 0;
70
+
71
+ // Commits by month
72
+ const monthlyRaw = git(`log --since="${since}" --format="%ad" --date=format:"%Y-%m" | sort | uniq -c`);
73
+ const monthly = {};
74
+ monthlyRaw.split('\n').filter(Boolean).forEach(line => {
75
+ const match = line.trim().match(/(\d+)\s+(\d{4}-\d{2})/);
76
+ if (match) {
77
+ monthly[match[2]] = parseInt(match[1]);
78
+ }
79
+ });
80
+
81
+ // Commits by day of week
82
+ const dayOfWeekRaw = git(`log --since="${since}" --format="%ad" --date=format:"%A" | sort | uniq -c | sort -rn`);
83
+ const dayOfWeek = {};
84
+ dayOfWeekRaw.split('\n').filter(Boolean).forEach(line => {
85
+ const match = line.trim().match(/(\d+)\s+(\w+)/);
86
+ if (match) {
87
+ dayOfWeek[match[2]] = parseInt(match[1]);
88
+ }
89
+ });
90
+
91
+ // Commits by hour
92
+ const hourRaw = git(`log --since="${since}" --format="%ad" --date=format:"%H" | sort | uniq -c | sort -rn | head -5`);
93
+ const topHours = [];
94
+ hourRaw.split('\n').filter(Boolean).forEach(line => {
95
+ const match = line.trim().match(/(\d+)\s+(\d+)/);
96
+ if (match) {
97
+ topHours.push({ hour: parseInt(match[2]), count: parseInt(match[1]) });
98
+ }
99
+ });
100
+
101
+ // Bug fixes vs features
102
+ const bugFixes = parseInt(git(`log --since="${since}" --oneline | grep -iE "fix|bug" | wc -l`)) || 0;
103
+ const features = parseInt(git(`log --since="${since}" --oneline | grep -iE "feat|add|implement" | wc -l`)) || 0;
104
+
105
+ // Claude Code commits
106
+ const claudeCommits = parseInt(git(`log --since="${since}" --format="%B" | grep -c "Co-Authored-By:"`)) || 0;
107
+
108
+ // First and last commit
109
+ const firstCommit = git(`log --since="${since}" --reverse --format="%ad|%s" --date=short | head -1`);
110
+ const lastCommit = git(`log --since="${since}" --format="%ad|%s" --date=short | head -1`);
111
+
112
+ // Most productive days
113
+ const productiveDaysRaw = git(`log --since="${since}" --format="%ad" --date=short | sort | uniq -c | sort -rn | head -5`);
114
+ const productiveDays = [];
115
+ productiveDaysRaw.split('\n').filter(Boolean).forEach(line => {
116
+ const match = line.trim().match(/(\d+)\s+(\d{4}-\d{2}-\d{2})/);
117
+ if (match) {
118
+ productiveDays.push({ date: match[2], count: parseInt(match[1]) });
119
+ }
120
+ });
121
+
122
+ // Unique days with commits
123
+ const uniqueDays = parseInt(git(`log --since="${since}" --format="%ad" --date=short | uniq | wc -l`)) || 0;
124
+
125
+ // Most edited files
126
+ const topFilesRaw = git(`log --since="${since}" --numstat --format="" | awk '{print $3}' | grep -v "^$" | sed 's|.*/||' | sort | uniq -c | sort -rn | head -5`);
127
+ const topFiles = [];
128
+ topFilesRaw.split('\n').filter(Boolean).forEach(line => {
129
+ const match = line.trim().match(/(\d+)\s+(.+)/);
130
+ if (match) {
131
+ topFiles.push({ file: match[2], count: parseInt(match[1]) });
132
+ }
133
+ });
134
+
135
+ // Repo name
136
+ const repoName = path.basename(process.cwd());
137
+
138
+ return {
139
+ repoName,
140
+ year,
141
+ totalCommits,
142
+ linesAdded,
143
+ linesDeleted,
144
+ netLines: linesAdded - linesDeleted,
145
+ monthly,
146
+ dayOfWeek,
147
+ topHours,
148
+ bugFixes,
149
+ features,
150
+ claudeCommits,
151
+ claudePercentage: totalCommits > 0 ? ((claudeCommits / totalCommits) * 100).toFixed(1) : 0,
152
+ firstCommit: firstCommit.split('|'),
153
+ lastCommit: lastCommit.split('|'),
154
+ productiveDays,
155
+ uniqueDays,
156
+ topFiles
157
+ };
158
+ }
159
+
160
+ // Get Claude Code stats
161
+ function getClaudeStats(year) {
162
+ const statsPath = path.join(os.homedir(), '.claude', 'stats-cache.json');
163
+
164
+ if (!fs.existsSync(statsPath)) {
165
+ return null;
166
+ }
167
+
168
+ try {
169
+ const data = JSON.parse(fs.readFileSync(statsPath, 'utf-8'));
170
+ const yearStr = year.toString();
171
+
172
+ // Filter daily activity by year
173
+ const dailyActivity = (data.dailyActivity || []).filter(d => d.date && d.date.startsWith(yearStr));
174
+
175
+ // Filter daily tokens by year
176
+ const dailyTokens = (data.dailyModelTokens || []).filter(d => d.date && d.date.startsWith(yearStr));
177
+
178
+ // If no data for this year, return null
179
+ if (dailyActivity.length === 0 && dailyTokens.length === 0) {
180
+ return null;
181
+ }
182
+
183
+ // Calculate tokens from daily data for the specified year
184
+ let totalTokens = 0;
185
+ let modelName = 'Claude';
186
+
187
+ dailyTokens.forEach(day => {
188
+ for (const [model, tokens] of Object.entries(day.tokensByModel || {})) {
189
+ totalTokens += tokens;
190
+ modelName = model.replace(/-\d+$/, '').replace('claude-', 'Claude ').replace('-', ' ');
191
+ }
192
+ });
193
+
194
+ // Calculate activity stats from filtered data
195
+ const totalMessages = dailyActivity.reduce((sum, d) => sum + (d.messageCount || 0), 0);
196
+ const totalToolCalls = dailyActivity.reduce((sum, d) => sum + (d.toolCallCount || 0), 0);
197
+ const totalSessions = dailyActivity.reduce((sum, d) => sum + (d.sessionCount || 0), 0);
198
+
199
+ // Most active day (from filtered data)
200
+ const mostActiveDay = dailyActivity.reduce((max, d) =>
201
+ d.messageCount > (max?.messageCount || 0) ? d : max, null);
202
+
203
+ // If we have no meaningful data, return null
204
+ if (totalMessages === 0 && totalTokens === 0) {
205
+ return null;
206
+ }
207
+
208
+ return {
209
+ totalTokens,
210
+ inputTokens: 0, // Not available in daily breakdown
211
+ outputTokens: 0,
212
+ cacheReadTokens: 0,
213
+ cacheCreationTokens: 0,
214
+ totalMessages,
215
+ totalToolCalls,
216
+ totalSessions,
217
+ modelName,
218
+ mostActiveDay: mostActiveDay ? {
219
+ date: mostActiveDay.date,
220
+ messages: mostActiveDay.messageCount
221
+ } : null,
222
+ firstSessionDate: data.firstSessionDate
223
+ };
224
+ } catch (e) {
225
+ console.error('Error reading Claude stats:', e.message);
226
+ return null;
227
+ }
228
+ }
229
+
230
+ // Smart title case for project names
231
+ function titleCase(str) {
232
+ if (!str) return str;
233
+
234
+ const hasUpperCase = /[A-Z]/.test(str);
235
+ const hasLowerCase = /[a-z]/.test(str);
236
+ const hasSeparators = str.includes('-') || str.includes('_');
237
+
238
+ // PascalCase like DocuProc - keep as-is
239
+ if (hasUpperCase && hasLowerCase && !hasSeparators && str[0] === str[0].toUpperCase()) {
240
+ return str;
241
+ }
242
+
243
+ // camelCase like myProject - add spaces and capitalize
244
+ if (hasUpperCase && hasLowerCase && !hasSeparators) {
245
+ return str.charAt(0).toUpperCase() + str.slice(1).replace(/([a-z])([A-Z])/g, '$1 $2');
246
+ }
247
+
248
+ // kebab-case or snake_case - replace separators and title case
249
+ let result = str.replace(/[-_]/g, ' ');
250
+ result = result.replace(/\b\w/g, c => c.toUpperCase());
251
+
252
+ // Handle common abbreviations that should be all caps
253
+ const allCaps = ['api', 'ui', 'cli', 'sdk', 'ai', 'ml', 'db', 'js', 'ts', 'css', 'html', 'http', 'url', 'id', 'cc'];
254
+ allCaps.forEach(word => {
255
+ const regex = new RegExp(`\\b${word}\\b`, 'gi');
256
+ result = result.replace(regex, word.toUpperCase());
257
+ });
258
+
259
+ return result;
260
+ }
261
+
262
+ // Format numbers
263
+ function formatNumber(num) {
264
+ if (num >= 1e9) return (num / 1e9).toFixed(1) + 'B';
265
+ if (num >= 1e6) return (num / 1e6).toFixed(1) + 'M';
266
+ if (num >= 1e3) return (num / 1e3).toFixed(0) + 'K';
267
+ return num.toLocaleString();
268
+ }
269
+
270
+ // Generate HTML
271
+ function generateHTML(gitStats, claudeStats, options) {
272
+ const { tokensOnly, gitOnly, year, repoName } = options;
273
+
274
+ const title = titleCase(repoName || gitStats?.repoName) || 'Your Code';
275
+
276
+ return `<!DOCTYPE html>
277
+ <html lang="en">
278
+ <head>
279
+ <meta charset="UTF-8">
280
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
281
+ <title>${title} ${year} Wrapped</title>
282
+ <style>
283
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&display=swap');
284
+
285
+ * { margin: 0; padding: 0; box-sizing: border-box; }
286
+
287
+ body {
288
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
289
+ background: linear-gradient(135deg, #0d0d0d 0%, #1a1a1a 50%, #0d0d0d 100%);
290
+ min-height: 100vh;
291
+ color: #fff;
292
+ padding: 40px 20px;
293
+ }
294
+
295
+ .container { max-width: 800px; margin: 0 auto; }
296
+
297
+ .header { text-align: center; margin-bottom: 50px; }
298
+ .header h1 {
299
+ font-size: 3.5rem;
300
+ font-weight: 900;
301
+ background: linear-gradient(135deg, #ffffff 0%, #a0a0a0 100%);
302
+ -webkit-background-clip: text;
303
+ -webkit-text-fill-color: transparent;
304
+ background-clip: text;
305
+ margin-bottom: 10px;
306
+ }
307
+ .header .year {
308
+ font-size: 5rem;
309
+ font-weight: 900;
310
+ color: #fff;
311
+ text-shadow: 0 0 40px rgba(255, 255, 255, 0.2);
312
+ }
313
+ .header .subtitle { font-size: 1.2rem; color: #888; margin-top: 10px; }
314
+
315
+ .card {
316
+ background: rgba(255, 255, 255, 0.03);
317
+ backdrop-filter: blur(10px);
318
+ border-radius: 24px;
319
+ padding: 30px;
320
+ margin-bottom: 24px;
321
+ border: 1px solid rgba(255, 255, 255, 0.08);
322
+ }
323
+ .card h2 { font-size: 1.5rem; margin-bottom: 20px; color: #fff; }
324
+
325
+ .big-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
326
+ @media (min-width: 600px) { .big-stats { grid-template-columns: repeat(4, 1fr); } }
327
+
328
+ .stat-box {
329
+ background: rgba(255, 255, 255, 0.05);
330
+ border-radius: 16px;
331
+ padding: 24px;
332
+ text-align: center;
333
+ border: 1px solid rgba(255, 255, 255, 0.05);
334
+ }
335
+ .stat-box .number {
336
+ font-size: 2rem;
337
+ font-weight: 800;
338
+ color: #fff;
339
+ }
340
+ .stat-box .label { font-size: 0.9rem; color: #666; margin-top: 5px; }
341
+
342
+ .bar-chart { margin: 15px 0; }
343
+ .bar-row { display: flex; align-items: center; margin-bottom: 12px; }
344
+ .bar-label { width: 100px; font-size: 0.9rem; color: #888; }
345
+ .bar-container {
346
+ flex: 1;
347
+ height: 24px;
348
+ background: rgba(255, 255, 255, 0.05);
349
+ border-radius: 12px;
350
+ overflow: hidden;
351
+ margin: 0 15px;
352
+ }
353
+ .bar {
354
+ height: 100%;
355
+ border-radius: 12px;
356
+ display: flex;
357
+ align-items: center;
358
+ padding-left: 10px;
359
+ font-size: 0.8rem;
360
+ font-weight: 600;
361
+ }
362
+ .bar.purple { background: linear-gradient(90deg, #404040 0%, #606060 100%); }
363
+ .bar.pink { background: linear-gradient(90deg, #505050 0%, #707070 100%); }
364
+ .bar.blue { background: linear-gradient(90deg, #3a3a3a 0%, #5a5a5a 100%); }
365
+ .bar.green { background: linear-gradient(90deg, #454545 0%, #656565 100%); }
366
+ .bar.orange { background: linear-gradient(90deg, #4a4a4a 0%, #6a6a6a 100%); }
367
+ .bar-value { font-size: 0.9rem; color: #fff; min-width: 50px; text-align: right; }
368
+
369
+ .highlight-box {
370
+ background: rgba(255, 255, 255, 0.05);
371
+ border-radius: 16px;
372
+ padding: 20px;
373
+ text-align: center;
374
+ margin-top: 20px;
375
+ border: 1px solid rgba(255, 255, 255, 0.08);
376
+ }
377
+ .highlight-box .label { color: #888; font-size: 0.9rem; }
378
+ .highlight-box .value { font-size: 1.5rem; font-weight: 700; color: #fff; margin-top: 5px; }
379
+
380
+ .achievements { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; }
381
+ .achievement {
382
+ background: rgba(255, 255, 255, 0.03);
383
+ border-radius: 12px;
384
+ padding: 15px;
385
+ display: flex;
386
+ align-items: center;
387
+ gap: 12px;
388
+ border: 1px solid rgba(255, 255, 255, 0.05);
389
+ }
390
+ .achievement .emoji { font-size: 1.8rem; }
391
+ .achievement .text { font-size: 0.85rem; }
392
+ .achievement .title { font-weight: 700; color: #fff; }
393
+ .achievement .desc { color: #888; font-size: 0.8rem; }
394
+
395
+ .footer { text-align: center; margin-top: 50px; padding: 30px; }
396
+ .footer .message { font-size: 1.1rem; color: #888; margin-bottom: 10px; }
397
+ .footer .cta {
398
+ font-size: 2rem;
399
+ font-weight: 800;
400
+ color: #fff;
401
+ }
402
+ .footer .credit { font-size: 0.8rem; color: #555; margin-top: 20px; }
403
+ .footer .credit a { color: #888; text-decoration: none; }
404
+
405
+ @media print {
406
+ body { background: #1a1a2e; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
407
+ }
408
+ </style>
409
+ </head>
410
+ <body>
411
+ <div class="container">
412
+ <div class="header">
413
+ <h1>${title}</h1>
414
+ <div class="year">${year}</div>
415
+ <div class="subtitle">${tokensOnly ? 'Claude Code Usage' : (gitOnly ? 'Git Stats' : 'Your Year in Code')}</div>
416
+ </div>
417
+
418
+ ${generateBigNumbers(gitStats, claudeStats, options)}
419
+ ${!tokensOnly && gitStats ? generateGitSections(gitStats) : ''}
420
+ ${!gitOnly && claudeStats ? generateTokenSections(claudeStats) : ''}
421
+ ${generateAchievements(gitStats, claudeStats, options)}
422
+
423
+ <div class="footer">
424
+ <div class="message">${getFooterMessage(gitStats, claudeStats, options)}</div>
425
+ <div class="cta">Here's to ${year + 1}!</div>
426
+ <div class="credit">Generated with <a href="https://github.com/yourusername/cc-wrapped">cc-wrapped</a></div>
427
+ </div>
428
+ </div>
429
+ </body>
430
+ </html>`;
431
+ }
432
+
433
+ function generateBigNumbers(gitStats, claudeStats, options) {
434
+ const { tokensOnly, gitOnly } = options;
435
+ let stats = [];
436
+
437
+ if (!gitOnly && claudeStats) {
438
+ stats.push({ number: formatNumber(claudeStats.totalTokens), label: 'Tokens Used' });
439
+ stats.push({ number: formatNumber(claudeStats.totalMessages), label: 'Messages Sent' });
440
+ stats.push({ number: formatNumber(claudeStats.totalToolCalls), label: 'Tool Calls' });
441
+ }
442
+
443
+ if (!tokensOnly && gitStats) {
444
+ stats.push({ number: formatNumber(gitStats.totalCommits), label: 'Commits' });
445
+ if (!claudeStats || gitOnly) {
446
+ stats.push({ number: formatNumber(gitStats.linesAdded), label: 'Lines Added' });
447
+ }
448
+ }
449
+
450
+ if (!gitOnly && claudeStats) {
451
+ stats.push({ number: claudeStats.totalSessions.toString(), label: 'Sessions' });
452
+ }
453
+
454
+ if (!tokensOnly && gitStats) {
455
+ stats.push({ number: formatNumber(gitStats.linesAdded), label: 'Lines Added' });
456
+ stats.push({ number: gitStats.uniqueDays.toString(), label: 'Days Coding' });
457
+ }
458
+
459
+ // Deduplicate and limit to 8
460
+ const seen = new Set();
461
+ stats = stats.filter(s => {
462
+ if (seen.has(s.label)) return false;
463
+ seen.add(s.label);
464
+ return true;
465
+ }).slice(0, 8);
466
+
467
+ return `
468
+ <div class="card">
469
+ <h2>The Big Numbers</h2>
470
+ <div class="big-stats">
471
+ ${stats.map(s => `
472
+ <div class="stat-box">
473
+ <div class="number">${s.number}</div>
474
+ <div class="label">${s.label}</div>
475
+ </div>
476
+ `).join('')}
477
+ </div>
478
+ </div>
479
+ `;
480
+ }
481
+
482
+ function generateGitSections(stats) {
483
+ const days = Object.entries(stats.dayOfWeek).sort((a, b) => b[1] - a[1]);
484
+ const maxDayCount = days[0]?.[1] || 1;
485
+ const colors = ['purple', 'pink', 'blue', 'orange', 'green'];
486
+
487
+ const months = Object.entries(stats.monthly).sort((a, b) => a[0].localeCompare(b[0]));
488
+ const maxMonthCount = Math.max(...months.map(m => m[1])) || 1;
489
+
490
+ const topHours = stats.topHours.slice(0, 3);
491
+ const maxHourCount = topHours[0]?.count || 1;
492
+
493
+ return `
494
+ <div class="card">
495
+ <h2>Monthly Activity</h2>
496
+ <div class="bar-chart">
497
+ ${months.map(([month, count], i) => `
498
+ <div class="bar-row">
499
+ <div class="bar-label">${new Date(month + '-01').toLocaleString('en', { month: 'short' })}</div>
500
+ <div class="bar-container">
501
+ <div class="bar ${colors[i % colors.length]}" style="width: ${(count / maxMonthCount) * 100}%;">${count}</div>
502
+ </div>
503
+ <div class="bar-value">${count}</div>
504
+ </div>
505
+ `).join('')}
506
+ </div>
507
+ </div>
508
+
509
+ <div class="card">
510
+ <h2>Favorite Day: ${days[0]?.[0] || 'N/A'}</h2>
511
+ <div class="bar-chart">
512
+ ${days.slice(0, 5).map(([day, count], i) => `
513
+ <div class="bar-row">
514
+ <div class="bar-label">${day.slice(0, 3)}</div>
515
+ <div class="bar-container">
516
+ <div class="bar ${colors[i % colors.length]}" style="width: ${(count / maxDayCount) * 100}%;"></div>
517
+ </div>
518
+ <div class="bar-value">${count}</div>
519
+ </div>
520
+ `).join('')}
521
+ </div>
522
+ </div>
523
+
524
+ ${topHours.length > 0 ? `
525
+ <div class="card">
526
+ <h2>Most Productive Hours</h2>
527
+ <div class="bar-chart">
528
+ ${topHours.map((h, i) => `
529
+ <div class="bar-row">
530
+ <div class="bar-label">${h.hour === 0 ? '12 AM' : h.hour < 12 ? h.hour + ' AM' : h.hour === 12 ? '12 PM' : (h.hour - 12) + ' PM'}</div>
531
+ <div class="bar-container">
532
+ <div class="bar ${colors[i % colors.length]}" style="width: ${(h.count / maxHourCount) * 100}%;"></div>
533
+ </div>
534
+ <div class="bar-value">${h.count}</div>
535
+ </div>
536
+ `).join('')}
537
+ </div>
538
+ ${topHours[0]?.hour >= 22 || topHours[0]?.hour <= 4 ? '<div class="highlight-box"><div class="label">Night Owl Developer!</div></div>' : ''}
539
+ </div>
540
+ ` : ''}
541
+
542
+ ${stats.claudeCommits > 0 ? `
543
+ <div class="card">
544
+ <h2>Claude Code Partnership</h2>
545
+ <div style="display: flex; align-items: center; justify-content: center; gap: 20px; margin-top: 20px;">
546
+ <div style="font-size: 3rem; font-weight: 900; color: #fff;">${stats.claudePercentage}%</div>
547
+ <div style="color: #888;">
548
+ <strong style="color: #fff;">${stats.claudeCommits} commits</strong><br>
549
+ made with Claude Code
550
+ </div>
551
+ </div>
552
+ </div>
553
+ ` : ''}
554
+ `;
555
+ }
556
+
557
+ function generateTokenSections(stats) {
558
+ const maxTokens = Math.max(stats.outputTokens, stats.inputTokens, stats.cacheReadTokens / 100, stats.cacheCreationTokens / 10) || 1;
559
+
560
+ return `
561
+ <div class="card">
562
+ <h2>Token Breakdown</h2>
563
+ <div class="bar-chart">
564
+ <div class="bar-row">
565
+ <div class="bar-label" style="width: 120px;">Output</div>
566
+ <div class="bar-container">
567
+ <div class="bar purple" style="width: ${Math.min((stats.outputTokens / maxTokens) * 100, 100)}%;"></div>
568
+ </div>
569
+ <div class="bar-value">${formatNumber(stats.outputTokens)}</div>
570
+ </div>
571
+ <div class="bar-row">
572
+ <div class="bar-label" style="width: 120px;">Input</div>
573
+ <div class="bar-container">
574
+ <div class="bar pink" style="width: ${Math.min((stats.inputTokens / maxTokens) * 100, 100)}%;"></div>
575
+ </div>
576
+ <div class="bar-value">${formatNumber(stats.inputTokens)}</div>
577
+ </div>
578
+ <div class="bar-row">
579
+ <div class="bar-label" style="width: 120px;">Cache Read</div>
580
+ <div class="bar-container">
581
+ <div class="bar blue" style="width: 100%;"></div>
582
+ </div>
583
+ <div class="bar-value">${formatNumber(stats.cacheReadTokens)}</div>
584
+ </div>
585
+ <div class="bar-row">
586
+ <div class="bar-label" style="width: 120px;">Cache Created</div>
587
+ <div class="bar-container">
588
+ <div class="bar green" style="width: ${Math.min((stats.cacheCreationTokens / stats.cacheReadTokens) * 100, 100)}%;"></div>
589
+ </div>
590
+ <div class="bar-value">${formatNumber(stats.cacheCreationTokens)}</div>
591
+ </div>
592
+ </div>
593
+ ${stats.mostActiveDay ? `
594
+ <div class="highlight-box">
595
+ <div class="label">Most Active Day</div>
596
+ <div class="value">${stats.mostActiveDay.date} - ${formatNumber(stats.mostActiveDay.messages)} messages!</div>
597
+ </div>
598
+ ` : ''}
599
+ <p style="text-align: center; color: #a0aec0; font-size: 0.9rem; margin-top: 15px;">Powered by ${stats.modelName}</p>
600
+ </div>
601
+ `;
602
+ }
603
+
604
+ function generateAchievements(gitStats, claudeStats, options) {
605
+ const achievements = [];
606
+
607
+ if (claudeStats) {
608
+ if (claudeStats.totalTokens >= 1e9) {
609
+ achievements.push({ emoji: 'šŸ”„', title: 'Token Titan', desc: formatNumber(claudeStats.totalTokens) + ' tokens' });
610
+ } else if (claudeStats.totalTokens >= 1e6) {
611
+ achievements.push({ emoji: '⚔', title: 'Token Master', desc: formatNumber(claudeStats.totalTokens) + ' tokens' });
612
+ }
613
+
614
+ if (claudeStats.totalMessages >= 10000) {
615
+ achievements.push({ emoji: 'šŸ’¬', title: 'Conversation King', desc: formatNumber(claudeStats.totalMessages) + ' messages' });
616
+ }
617
+
618
+ if (claudeStats.totalToolCalls >= 5000) {
619
+ achievements.push({ emoji: 'šŸ› ļø', title: 'Tool Wielder', desc: formatNumber(claudeStats.totalToolCalls) + ' tool calls' });
620
+ }
621
+ }
622
+
623
+ if (gitStats) {
624
+ if (gitStats.totalCommits >= 500) {
625
+ achievements.push({ emoji: 'šŸ’Æ', title: 'Commit Machine', desc: gitStats.totalCommits + ' commits' });
626
+ } else if (gitStats.totalCommits >= 100) {
627
+ achievements.push({ emoji: 'šŸŽÆ', title: 'Century Club', desc: gitStats.totalCommits + ' commits' });
628
+ }
629
+
630
+ if (gitStats.bugFixes >= 100) {
631
+ achievements.push({ emoji: 'šŸ›', title: 'Bug Exterminator', desc: gitStats.bugFixes + ' bugs squashed' });
632
+ }
633
+
634
+ if (gitStats.claudeCommits > 0 && parseFloat(gitStats.claudePercentage) >= 50) {
635
+ achievements.push({ emoji: 'šŸ¤', title: 'Dynamic Duo', desc: gitStats.claudePercentage + '% with Claude' });
636
+ }
637
+
638
+ const nightCommits = gitStats.topHours.filter(h => h.hour >= 22 || h.hour <= 4).reduce((s, h) => s + h.count, 0);
639
+ if (nightCommits >= 20) {
640
+ achievements.push({ emoji: 'šŸŒ™', title: 'Night Owl', desc: nightCommits + ' late-night commits' });
641
+ }
642
+ }
643
+
644
+ if (achievements.length === 0) {
645
+ achievements.push({ emoji: 'šŸš€', title: 'Builder', desc: 'Shipped code this year!' });
646
+ }
647
+
648
+ return `
649
+ <div class="card">
650
+ <h2>Achievements Unlocked</h2>
651
+ <div class="achievements">
652
+ ${achievements.map(a => `
653
+ <div class="achievement">
654
+ <div class="emoji">${a.emoji}</div>
655
+ <div class="text">
656
+ <div class="title">${a.title}</div>
657
+ <div class="desc">${a.desc}</div>
658
+ </div>
659
+ </div>
660
+ `).join('')}
661
+ </div>
662
+ </div>
663
+ `;
664
+ }
665
+
666
+ function getFooterMessage(gitStats, claudeStats, options) {
667
+ if (options.tokensOnly) {
668
+ return `${formatNumber(claudeStats?.totalTokens || 0)} tokens of AI-powered development`;
669
+ }
670
+ if (options.gitOnly) {
671
+ return `${gitStats?.totalCommits || 0} commits of building something great`;
672
+ }
673
+ return `From first commit to production - what a journey!`;
674
+ }
675
+
676
+ // Get repo name without full git stats
677
+ function getRepoName() {
678
+ if (isGitRepo()) {
679
+ return path.basename(process.cwd());
680
+ }
681
+ return null;
682
+ }
683
+
684
+ // Main function
685
+ async function generateWrapped(options) {
686
+ const { tokensOnly, gitOnly, year, outputPath, openBrowser } = options;
687
+
688
+ console.log(`\nšŸŽ Generating your ${year} Wrapped...\n`);
689
+
690
+ let gitStats = null;
691
+ let claudeStats = null;
692
+ let repoName = getRepoName();
693
+
694
+ // Get git stats if needed
695
+ if (!tokensOnly) {
696
+ if (!isGitRepo()) {
697
+ if (gitOnly) {
698
+ console.error('āŒ Not a git repository. Run this command inside a git repo.');
699
+ process.exit(1);
700
+ }
701
+ console.log('āš ļø Not a git repository. Skipping git stats.');
702
+ } else {
703
+ const gitSpinner = new Spinner('Analyzing git history...');
704
+ gitSpinner.start();
705
+ gitStats = getGitStats(year);
706
+ gitSpinner.succeed(`Found ${gitStats.totalCommits} commits`);
707
+ }
708
+ }
709
+
710
+ // Get Claude stats if needed
711
+ if (!gitOnly) {
712
+ const claudeSpinner = new Spinner(`Looking for Claude Code stats for ${year}...`);
713
+ claudeSpinner.start();
714
+ claudeStats = getClaudeStats(year);
715
+ if (claudeStats) {
716
+ claudeSpinner.succeed(`Found ${formatNumber(claudeStats.totalTokens)} tokens used in ${year}`);
717
+ } else {
718
+ if (tokensOnly) {
719
+ claudeSpinner.fail(`No Claude Code stats found for ${year}`);
720
+ process.exit(1);
721
+ }
722
+ claudeSpinner.succeed(`No Claude Code stats found for ${year} (that's okay!)`);
723
+ }
724
+ }
725
+
726
+ // Generate HTML
727
+ const genSpinner = new Spinner('Generating wrapped...');
728
+ genSpinner.start();
729
+ const html = generateHTML(gitStats, claudeStats, { ...options, repoName });
730
+ genSpinner.succeed('Wrapped generated!');
731
+
732
+ // Write file
733
+ const fullPath = path.resolve(outputPath);
734
+ fs.writeFileSync(fullPath, html);
735
+ console.log(`šŸ“„ Saved to ${fullPath}`);
736
+
737
+ // Open in browser
738
+ if (openBrowser) {
739
+ console.log(`🌐 Opening in browser (or open manually: ${fullPath})`);
740
+ try {
741
+ const open = (await import('open')).default;
742
+ await open(fullPath);
743
+ } catch (e) {
744
+ // Fallback to native commands if open package fails
745
+ try {
746
+ const platform = process.platform;
747
+ if (platform === 'win32') {
748
+ execSync(`start "" "${fullPath}"`, { stdio: 'ignore' });
749
+ } else if (platform === 'darwin') {
750
+ execSync(`open "${fullPath}"`, { stdio: 'ignore' });
751
+ } else {
752
+ execSync(`xdg-open "${fullPath}"`, { stdio: 'ignore' });
753
+ }
754
+ } catch (fallbackErr) {
755
+ // Message already shown above with file path
756
+ }
757
+ }
758
+ }
759
+
760
+ console.log('\nšŸŽ‰ Done! Share your wrapped on social media!\n');
761
+ }
762
+
763
+ module.exports = { generateWrapped };