@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.
- package/.github/workflows/publish.yml +26 -0
- package/README.md +87 -0
- package/bin/cc-wrapped.js +28 -0
- package/package.json +34 -0
- package/src/index.js +763 -0
|
@@ -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
|
+

|
|
6
|
+

|
|
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 };
|