git-flex 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LarsenCundric
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # git-flex
2
+
3
+ Show off your coding stats in style. Run `flex` in any git repo and get a beautiful terminal card with your stats, streaks, and personalized highlights.
4
+
5
+ <p align="center">
6
+ <img src="demo-card.svg" alt="flex card demo" width="480" />
7
+ </p>
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g git-flex
13
+ ```
14
+
15
+ Or try it without installing:
16
+
17
+ ```bash
18
+ npx git-flex
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ # Today's stats
25
+ flex
26
+
27
+ # This week / month / all time
28
+ flex week
29
+ flex month
30
+ flex all
31
+
32
+ # Compare with a teammate
33
+ flex vs @teammate
34
+
35
+ # Team leaderboard
36
+ flex team
37
+
38
+ # Commit streak
39
+ flex streak
40
+
41
+ # Language breakdown
42
+ flex langs
43
+
44
+ # Generate a shareable SVG card
45
+ flex card
46
+ flex card --light
47
+ flex card --dark -o my-card.svg
48
+ ```
49
+
50
+ ## What You Get
51
+
52
+ - **Commits, lines added/removed, net lines, files touched**
53
+ - **Language breakdown** with visual bars
54
+ - **Peak coding hour** — when you're most productive
55
+ - **Commit streak** — current and all-time best
56
+ - **Fun rank** based on your patterns: Night Owl, Bug Slayer, Commit Machine, Code Surgeon...
57
+ - **Level system** — Newcomer to Legendary based on commit volume
58
+ - **Personalized highlights** — auto-generated one-liners based on your actual data:
59
+ - *"250 weekend commits... touch grass"*
60
+ - *"8 Friday afternoon deploys — lives dangerously"*
61
+ - *"Deleted more than you wrote — mass cleanup arc"*
62
+ - *"+82K net lines — entire features in one go"*
63
+ - *"If you leave, this repo is cooked"*
64
+
65
+ ## SVG Card
66
+
67
+ Generate a card you can embed in your GitHub README or share anywhere:
68
+
69
+ ```bash
70
+ flex card # dark theme (default)
71
+ flex card --light # light theme
72
+ flex card -o stats.svg # custom output file
73
+ ```
74
+
75
+ Then add it to your README:
76
+
77
+ ```markdown
78
+ ![My coding stats](stats.svg)
79
+ ```
80
+
81
+ ## Requirements
82
+
83
+ - Node.js >= 18
84
+ - Git
85
+
86
+ ## License
87
+
88
+ MIT
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "git-flex",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Show off your coding stats in style. Beautiful terminal cards, shareable SVGs, and fun highlights from your git history.",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "flex": "src/index.js"
9
+ },
10
+ "scripts": {
11
+ "test": "echo \"Error: no test specified\" && exit 1"
12
+ },
13
+ "keywords": [
14
+ "git",
15
+ "stats",
16
+ "cli",
17
+ "developer-tools",
18
+ "coding-stats",
19
+ "git-stats",
20
+ "terminal",
21
+ "svg",
22
+ "streak",
23
+ "contributions",
24
+ "github"
25
+ ],
26
+ "author": "LarsenCundric",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/LarsenCundric/git-flex.git"
31
+ },
32
+ "homepage": "https://github.com/LarsenCundric/git-flex",
33
+ "bugs": {
34
+ "url": "https://github.com/LarsenCundric/git-flex/issues"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "files": [
40
+ "src/"
41
+ ],
42
+ "dependencies": {
43
+ "chalk": "5.6.2",
44
+ "commander": "12.1.0"
45
+ }
46
+ }
package/src/git.js ADDED
@@ -0,0 +1,134 @@
1
+ import { execSync, execFileSync } from 'node:child_process';
2
+
3
+ const STDIO = { encoding: 'utf-8', maxBuffer: 100 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] };
4
+
5
+ function run(cmd) {
6
+ try {
7
+ return execSync(cmd, STDIO).trim();
8
+ } catch {
9
+ return '';
10
+ }
11
+ }
12
+
13
+ function runArgs(args) {
14
+ try {
15
+ return execFileSync(args[0], args.slice(1), STDIO).trim();
16
+ } catch {
17
+ return '';
18
+ }
19
+ }
20
+
21
+ export function isGitRepo() {
22
+ return run('git rev-parse --is-inside-work-tree') === 'true';
23
+ }
24
+
25
+ export function getRepoName() {
26
+ const remote = run('git remote get-url origin');
27
+ if (remote) {
28
+ const match = remote.match(/\/([^/]+?)(\.git)?$/);
29
+ if (match) return match[1];
30
+ }
31
+ const toplevel = run('git rev-parse --show-toplevel');
32
+ return toplevel.split('/').pop() || 'unknown';
33
+ }
34
+
35
+ export function getCurrentUser() {
36
+ return run('git config user.name') || 'Unknown';
37
+ }
38
+
39
+ export function getCommits({ author, since, until } = {}) {
40
+ const args = ['git', 'log', '--no-merges', '--format=%H|%an|%ae|%aI|%s'];
41
+ if (author) args.push(`--author=${author}`);
42
+ if (since) args.push(`--since=${since}`);
43
+ if (until) args.push(`--until=${until}`);
44
+ const raw = runArgs(args);
45
+ if (!raw) return [];
46
+ return raw.split('\n').filter(Boolean).map(line => {
47
+ const [hash, name, email, date, ...msgParts] = line.split('|');
48
+ return { hash, name, email, date, message: msgParts.join('|') };
49
+ });
50
+ }
51
+
52
+ // Single git command to get all diff stats + file names + language info
53
+ export function getDiffStatsAndLangs({ author, since, until } = {}) {
54
+ const args = ['git', 'log', '--no-merges', '--numstat', '--format='];
55
+ if (author) args.push(`--author=${author}`);
56
+ if (since) args.push(`--since=${since}`);
57
+ if (until) args.push(`--until=${until}`);
58
+
59
+ const raw = runArgs(args);
60
+
61
+ let added = 0, removed = 0;
62
+ const files = new Set();
63
+ const fileChanges = {};
64
+ const extCount = {};
65
+
66
+ if (raw) {
67
+ for (const line of raw.split('\n')) {
68
+ if (!line || !line.includes('\t')) continue;
69
+ const parts = line.split('\t');
70
+ if (parts.length < 3) continue;
71
+ const [a, r, file] = parts;
72
+ if (a === '-') continue; // binary
73
+ const addNum = parseInt(a) || 0;
74
+ const remNum = parseInt(r) || 0;
75
+ added += addNum;
76
+ removed += remNum;
77
+ files.add(file);
78
+ fileChanges[file] = (fileChanges[file] || 0) + addNum + remNum;
79
+
80
+ const ext = file.includes('.') ? file.split('.').pop().toLowerCase() : 'other';
81
+ extCount[ext] = (extCount[ext] || 0) + 1;
82
+ }
83
+ }
84
+
85
+ return { added, removed, files, fileChanges, extCount };
86
+ }
87
+
88
+ export function getAllAuthors({ since, until } = {}) {
89
+ const args = ['git', 'log', '--no-merges', '--format=%an'];
90
+ if (since) args.push(`--since=${since}`);
91
+ if (until) args.push(`--until=${until}`);
92
+ const raw = runArgs(args);
93
+ if (!raw) return [];
94
+ const counts = {};
95
+ for (const name of raw.split('\n').filter(Boolean)) {
96
+ counts[name] = (counts[name] || 0) + 1;
97
+ }
98
+ return Object.entries(counts)
99
+ .map(([name, commits]) => ({ name, commits }))
100
+ .sort((a, b) => b.commits - a.commits);
101
+ }
102
+
103
+ export function getStreakData(author) {
104
+ const raw = runArgs(['git', 'log', '--no-merges', `--author=${author}`, '--format=%aI']);
105
+ if (!raw) return { current: 0, longest: 0 };
106
+
107
+ const dates = [...new Set(
108
+ raw.split('\n').filter(Boolean).map(d => d.slice(0, 10))
109
+ )].sort();
110
+
111
+ if (!dates.length) return { current: 0, longest: 0 };
112
+
113
+ let longest = 1, current = 1;
114
+ for (let i = 1; i < dates.length; i++) {
115
+ const prev = new Date(dates[i - 1]);
116
+ const curr = new Date(dates[i]);
117
+ const diff = (curr - prev) / (1000 * 60 * 60 * 24);
118
+ if (diff === 1) {
119
+ current++;
120
+ longest = Math.max(longest, current);
121
+ } else {
122
+ current = 1;
123
+ }
124
+ }
125
+
126
+ // Check if current streak is active (last commit today or yesterday)
127
+ const lastDate = new Date(dates[dates.length - 1]);
128
+ const today = new Date();
129
+ today.setHours(0, 0, 0, 0);
130
+ const diffDays = (today - lastDate) / (1000 * 60 * 60 * 24);
131
+ if (diffDays > 1) current = 0;
132
+
133
+ return { current, longest: Math.max(longest, current) };
134
+ }
@@ -0,0 +1,300 @@
1
+ export function getHighlights(stats, streakData) {
2
+ const pool = [];
3
+ const commits = stats.commitData || [];
4
+
5
+ // === TIME PATTERNS ===
6
+
7
+ // Biggest single day
8
+ const dayMap = {};
9
+ for (const c of commits) {
10
+ const d = c.date.slice(0, 10);
11
+ dayMap[d] = (dayMap[d] || 0) + 1;
12
+ }
13
+ const biggestDay = Object.entries(dayMap).sort((a, b) => b[1] - a[1])[0];
14
+ if (biggestDay && biggestDay[1] >= 3) {
15
+ const d = new Date(biggestDay[0]);
16
+ const month = d.toLocaleString('en', { month: 'short' });
17
+ const day = d.getDate();
18
+ pool.push({ text: `${biggestDay[1]} commits on ${month} ${day} — absolute machine`, weight: biggestDay[1] });
19
+ }
20
+
21
+ // Weekend commits
22
+ const weekendCommits = commits.filter(c => {
23
+ const day = new Date(c.date).getDay();
24
+ return day === 0 || day === 6;
25
+ });
26
+ if (weekendCommits.length >= 10) {
27
+ pool.push({ text: `${weekendCommits.length} weekend commits... touch grass`, weight: 6 });
28
+ } else if (weekendCommits.length >= 3) {
29
+ pool.push({ text: `${weekendCommits.length} weekend commits — no rest for the goated`, weight: 3 });
30
+ }
31
+ if (weekendCommits.length === 0 && commits.length >= 20) {
32
+ pool.push({ text: `Zero weekend commits — work-life balance king`, weight: 4 });
33
+ }
34
+
35
+ // Late night
36
+ const lateNight = commits.filter(c => {
37
+ const h = new Date(c.date).getHours();
38
+ return h >= 0 && h < 5;
39
+ });
40
+ if (lateNight.length >= 5) {
41
+ pool.push({ text: `${lateNight.length} commits between midnight and 5 AM... you okay?`, weight: 5 });
42
+ } else if (lateNight.length >= 1) {
43
+ pool.push({ text: `Caught coding at ${new Date(lateNight[0].date).getHours() || 12} AM — sus`, weight: 2 });
44
+ }
45
+
46
+ // Early bird
47
+ const earlyBird = commits.filter(c => {
48
+ const h = new Date(c.date).getHours();
49
+ return h >= 5 && h < 7;
50
+ });
51
+ if (earlyBird.length >= 10) {
52
+ pool.push({ text: `${earlyBird.length} commits before 7 AM — wakes up and ships`, weight: 5 });
53
+ }
54
+
55
+ // Friday afternoon shipper
56
+ const fridayPM = commits.filter(c => {
57
+ const d = new Date(c.date);
58
+ return d.getDay() === 5 && d.getHours() >= 15;
59
+ });
60
+ if (fridayPM.length >= 5) {
61
+ pool.push({ text: `${fridayPM.length} Friday afternoon deploys — lives dangerously`, weight: 6 });
62
+ } else if (fridayPM.length >= 1) {
63
+ pool.push({ text: `Pushed on a Friday afternoon at least once — bold`, weight: 3 });
64
+ }
65
+
66
+ // Monday morning warrior
67
+ const mondayAM = commits.filter(c => {
68
+ const d = new Date(c.date);
69
+ return d.getDay() === 1 && d.getHours() < 10;
70
+ });
71
+ if (mondayAM.length >= 10) {
72
+ pool.push({ text: `${mondayAM.length} Monday morning commits — hits the ground running`, weight: 4 });
73
+ }
74
+
75
+ // === VOLUME & IMPACT ===
76
+
77
+ // Deletion king
78
+ if (stats.removed > stats.added && stats.removed > 100) {
79
+ pool.push({ text: `Deleted more than you wrote — mass cleanup arc`, weight: 7 });
80
+ }
81
+
82
+ // Ratio flex
83
+ if (stats.added > 0 && stats.removed > 0) {
84
+ const ratio = stats.removed / stats.added;
85
+ if (ratio > 0.8 && ratio < 1.0) {
86
+ pool.push({ text: `Removes almost as much as they add — clean coder`, weight: 4 });
87
+ }
88
+ }
89
+
90
+ // Massive net additions
91
+ if (stats.net > 50000) {
92
+ pool.push({ text: `+${fmt(stats.net)} net lines — entire features in one go`, weight: 7 });
93
+ } else if (stats.net > 10000) {
94
+ pool.push({ text: `+${fmt(stats.net)} net lines — built a whole codebase`, weight: 6 });
95
+ } else if (stats.net > 1000) {
96
+ pool.push({ text: `+${fmt(stats.net)} net lines — shipping machine`, weight: 4 });
97
+ }
98
+
99
+ // Files touched
100
+ if (stats.filesCount >= 500) {
101
+ pool.push({ text: `Touched ${fmt(stats.filesCount)} files — knows every corner of this repo`, weight: 5 });
102
+ } else if (stats.filesCount >= 100) {
103
+ pool.push({ text: `${stats.filesCount} files touched — gets around`, weight: 3 });
104
+ }
105
+
106
+ // Commit volume
107
+ if (stats.commits >= 1000) {
108
+ pool.push({ text: `${fmt(stats.commits)} commits — this repo is basically yours`, weight: 7 });
109
+ } else if (stats.commits >= 500) {
110
+ pool.push({ text: `${fmt(stats.commits)} commits — major contributor energy`, weight: 5 });
111
+ } else if (stats.commits >= 100) {
112
+ pool.push({ text: `${stats.commits} commits — putting in the reps`, weight: 3 });
113
+ }
114
+
115
+ // Avg commits per active day
116
+ const activeDays = Object.keys(dayMap).length;
117
+ if (activeDays > 0) {
118
+ const avgPerDay = stats.commits / activeDays;
119
+ if (avgPerDay >= 5) {
120
+ pool.push({ text: `${avgPerDay.toFixed(1)} commits/day avg — rapid fire`, weight: 5 });
121
+ } else if (avgPerDay >= 3) {
122
+ pool.push({ text: `${avgPerDay.toFixed(1)} commits/day avg — consistent output`, weight: 3 });
123
+ }
124
+ }
125
+
126
+ // Active days
127
+ if (activeDays >= 200) {
128
+ pool.push({ text: `Active ${activeDays} days — basically lives here`, weight: 6 });
129
+ } else if (activeDays >= 100) {
130
+ pool.push({ text: `Active ${activeDays} days — showed up and shipped`, weight: 5 });
131
+ } else if (activeDays >= 30) {
132
+ pool.push({ text: `${activeDays} active days — locked in for a month+`, weight: 3 });
133
+ }
134
+
135
+ // === LANGUAGE ===
136
+
137
+ if (stats.languages.length && stats.languages[0].pct >= 80) {
138
+ const lang = stats.languages[0].name;
139
+ const quips = {
140
+ Python: `${stats.languages[0].pct}% Python — basically a snake at this point`,
141
+ TypeScript: `${stats.languages[0].pct}% TypeScript — type safety is a lifestyle`,
142
+ JavaScript: `${stats.languages[0].pct}% JavaScript — any% type coercion run`,
143
+ Rust: `${stats.languages[0].pct}% Rust — zero bugs, maximum suffering`,
144
+ Go: `${stats.languages[0].pct}% Go — if err != nil { respect++ }`,
145
+ Ruby: `${stats.languages[0].pct}% Ruby — a person of culture`,
146
+ Java: `${stats.languages[0].pct}% Java — AbstractFactoryBeanProxyManager`,
147
+ 'C++': `${stats.languages[0].pct}% C++ — dangerously close to the metal`,
148
+ C: `${stats.languages[0].pct}% C — talks directly to the CPU`,
149
+ 'C#': `${stats.languages[0].pct}% C# — Unity dev or enterprise warrior`,
150
+ PHP: `${stats.languages[0].pct}% PHP — and proud of it, apparently`,
151
+ Swift: `${stats.languages[0].pct}% Swift — Apple ecosystem locked in`,
152
+ Kotlin: `${stats.languages[0].pct}% Kotlin — modern Android royalty`,
153
+ Shell: `${stats.languages[0].pct}% Shell — automates everything`,
154
+ Dart: `${stats.languages[0].pct}% Dart — Flutter gang`,
155
+ Vue: `${stats.languages[0].pct}% Vue — the progressive framework enjoyer`,
156
+ Svelte: `${stats.languages[0].pct}% Svelte — no virtual DOM, no problem`,
157
+ Elixir: `${stats.languages[0].pct}% Elixir — functional and proud`,
158
+ Haskell: `${stats.languages[0].pct}% Haskell — a monad is just a monoid in the...`,
159
+ Lua: `${stats.languages[0].pct}% Lua — Neovim config or game dev, no in between`,
160
+ Zig: `${stats.languages[0].pct}% Zig — the chosen one`,
161
+ Scala: `${stats.languages[0].pct}% Scala — functional OOP hybrid enjoyer`,
162
+ };
163
+ pool.push({ text: quips[lang] || `${stats.languages[0].pct}% ${lang} — fully locked in`, weight: 5 });
164
+ } else if (stats.languages.length >= 6) {
165
+ pool.push({ text: `${stats.languages.length} languages — full-stack doesn't even cover it`, weight: 5 });
166
+ } else if (stats.languages.length >= 4) {
167
+ pool.push({ text: `${stats.languages.length} languages — polyglot mode activated`, weight: 4 });
168
+ }
169
+
170
+ // Frontend/backend detection
171
+ const langNames = stats.languages.map(l => l.name.toLowerCase());
172
+ const hasFrontend = langNames.some(l => ['react tsx', 'react jsx', 'vue', 'svelte', 'css', 'scss', 'html'].includes(l));
173
+ const hasBackend = langNames.some(l => ['python', 'go', 'rust', 'java', 'ruby', 'c#', 'elixir'].includes(l));
174
+ if (hasFrontend && hasBackend) {
175
+ pool.push({ text: `Frontend and backend — true full-stack`, weight: 4 });
176
+ }
177
+
178
+ // === STREAKS ===
179
+
180
+ if (streakData.current >= 60) {
181
+ pool.push({ text: `${streakData.current}-day streak — this is discipline`, weight: 9 });
182
+ } else if (streakData.current >= 30) {
183
+ pool.push({ text: `${streakData.current}-day streak — at this point it's a lifestyle`, weight: 8 });
184
+ } else if (streakData.current >= 14) {
185
+ pool.push({ text: `${streakData.current}-day streak — two weeks of pure lock-in`, weight: 6 });
186
+ } else if (streakData.current >= 7) {
187
+ pool.push({ text: `${streakData.current}-day streak and counting`, weight: 4 });
188
+ }
189
+ if (streakData.longest >= 60 && streakData.longest > streakData.current) {
190
+ pool.push({ text: `Once went ${streakData.longest} days straight — legendary run`, weight: 6 });
191
+ } else if (streakData.longest >= 30 && streakData.longest > streakData.current) {
192
+ pool.push({ text: `${streakData.longest}-day best streak — the grind was real`, weight: 5 });
193
+ }
194
+ if (streakData.current === 0 && streakData.longest >= 7) {
195
+ pool.push({ text: `Streak broken — redemption arc starts now`, weight: 3 });
196
+ }
197
+
198
+ // === COMMIT MESSAGE PATTERNS ===
199
+
200
+ const messages = commits.map(c => c.message.toLowerCase());
201
+
202
+ const fixes = messages.filter(m => /fix|bug|patch|hotfix/i.test(m));
203
+ if (fixes.length >= 50) {
204
+ pool.push({ text: `${fixes.length} bug fixes — born to debug`, weight: 5 });
205
+ } else if (fixes.length >= 20) {
206
+ pool.push({ text: `${fixes.length} bug fixes — the exterminator`, weight: 4 });
207
+ }
208
+
209
+ const refactors = messages.filter(m => /refactor|clean|restructure/i.test(m));
210
+ if (refactors.length >= 20) {
211
+ pool.push({ text: `${refactors.length} refactors — the codebase whisperer`, weight: 5 });
212
+ } else if (refactors.length >= 10) {
213
+ pool.push({ text: `${refactors.length} refactors — leaves it better than they found it`, weight: 4 });
214
+ }
215
+
216
+ const feats = messages.filter(m => /feat|feature|add|implement|new/i.test(m));
217
+ if (feats.length >= 50) {
218
+ pool.push({ text: `${feats.length} features shipped — product team's best friend`, weight: 5 });
219
+ } else if (feats.length >= 20) {
220
+ pool.push({ text: `${feats.length} features shipped — builder mentality`, weight: 4 });
221
+ }
222
+
223
+ const wip = messages.filter(m => /wip|work in progress|tmp|todo|hack/i.test(m));
224
+ if (wip.length >= 10) {
225
+ pool.push({ text: `${wip.length} WIP commits — moves fast, cleans up later`, weight: 3 });
226
+ }
227
+
228
+ const yolo = messages.filter(m => /yolo|lol|oops|forgot|ugh|fml|fuck|shit/i.test(m));
229
+ if (yolo.length >= 5) {
230
+ pool.push({ text: `${yolo.length} "oops" commits — keeping it real`, weight: 4 });
231
+ } else if (yolo.length >= 1) {
232
+ pool.push({ text: `At least one commit message they regret`, weight: 2 });
233
+ }
234
+
235
+ const initials = messages.filter(m => /^initial|^first|^init|^bootstrap|^setup/i.test(m));
236
+ if (initials.length >= 3) {
237
+ pool.push({ text: `${initials.length} repos bootstrapped — serial project starter`, weight: 4 });
238
+ }
239
+
240
+ // One-word commit messages
241
+ const oneWord = messages.filter(m => !m.includes(' '));
242
+ if (oneWord.length >= 10) {
243
+ pool.push({ text: `${oneWord.length} one-word commit messages — a person of few words`, weight: 3 });
244
+ }
245
+
246
+ // Long commit messages (verbose)
247
+ const verbose = messages.filter(m => m.length > 100);
248
+ if (verbose.length >= 10) {
249
+ pool.push({ text: `${verbose.length} commit essays — believes in documentation`, weight: 3 });
250
+ }
251
+
252
+ // === PEAK HOUR PERSONALITY ===
253
+
254
+ const { peakHour } = stats;
255
+ if (peakHour >= 5 && peakHour <= 7) {
256
+ pool.push({ text: `Peak hour: ${fmtHour(peakHour)} — codes before the sun`, weight: 3 });
257
+ } else if (peakHour >= 22 || peakHour <= 2) {
258
+ pool.push({ text: `Peak hour: ${fmtHour(peakHour)} — nocturnal programmer`, weight: 3 });
259
+ } else if (peakHour >= 12 && peakHour <= 13) {
260
+ pool.push({ text: `Peak hour: ${fmtHour(peakHour)} — lunch break diff machine`, weight: 3 });
261
+ } else if (peakHour >= 9 && peakHour <= 10) {
262
+ pool.push({ text: `Peak hour: ${fmtHour(peakHour)} — standup then straight to shipping`, weight: 3 });
263
+ } else if (peakHour >= 14 && peakHour <= 16) {
264
+ pool.push({ text: `Peak hour: ${fmtHour(peakHour)} — afternoon flow state`, weight: 3 });
265
+ }
266
+
267
+ // === FUN COMBOS ===
268
+
269
+ // Bus factor warning
270
+ if (stats.commits >= 500 && stats.filesCount >= 200) {
271
+ pool.push({ text: `If you leave, this repo is cooked`, weight: 6 });
272
+ }
273
+
274
+ // Micro-committer
275
+ if (stats.commits > 0 && stats.added / stats.commits < 10) {
276
+ pool.push({ text: `${(stats.added / stats.commits).toFixed(1)} lines/commit avg — atomic commits gang`, weight: 3 });
277
+ }
278
+
279
+ // Mega-committer
280
+ if (stats.commits > 0 && stats.added / stats.commits > 200) {
281
+ pool.push({ text: `${Math.round(stats.added / stats.commits)} lines/commit avg — go big or go home`, weight: 4 });
282
+ }
283
+
284
+ // Sort by weight, pick top 3
285
+ pool.sort((a, b) => b.weight - a.weight);
286
+ return pool.slice(0, 3).map(h => h.text);
287
+ }
288
+
289
+ function fmt(n) {
290
+ if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(1) + 'M';
291
+ if (Math.abs(n) >= 1000) return (n / 1000).toFixed(1) + 'K';
292
+ return n.toString();
293
+ }
294
+
295
+ function fmtHour(h) {
296
+ if (h === 0) return '12 AM';
297
+ if (h < 12) return `${h} AM`;
298
+ if (h === 12) return '12 PM';
299
+ return `${h - 12} PM`;
300
+ }
package/src/index.js ADDED
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { writeFileSync } from 'node:fs';
5
+ import { isGitRepo, getRepoName, getCurrentUser, getStreakData, getAllAuthors } from './git.js';
6
+ import { computeStats, getTimeRange } from './stats.js';
7
+ import { renderCard, renderComparison, renderTeam, renderStreak, renderLangs } from './terminal.js';
8
+ import { generateSVG } from './svg.js';
9
+
10
+ function ensureRepo() {
11
+ if (!isGitRepo()) {
12
+ console.error('Error: not a git repository. Run flex inside a git repo.');
13
+ process.exit(1);
14
+ }
15
+ }
16
+
17
+ const program = new Command();
18
+
19
+ program
20
+ .name('flex')
21
+ .description('Show off your coding stats')
22
+ .version('1.0.0')
23
+ .argument('[period]', 'today (default), week, month, or all')
24
+ .action((period) => {
25
+ ensureRepo();
26
+ period = period || 'today';
27
+ const { since, label } = getTimeRange(period);
28
+ const repoName = getRepoName();
29
+ const stats = computeStats({ since });
30
+ const streakData = getStreakData(getCurrentUser());
31
+ console.log(renderCard(stats, streakData, repoName, label));
32
+ });
33
+
34
+ program
35
+ .command('vs <author>')
36
+ .description('Compare your stats with a teammate')
37
+ .option('-p, --period <period>', 'today, week, month, all', 'all')
38
+ .action((author, opts) => {
39
+ ensureRepo();
40
+ const cleanAuthor = author.replace(/^@/, '');
41
+ const { since, label } = getTimeRange(opts.period);
42
+ const repoName = getRepoName();
43
+ const myStats = computeStats({ since });
44
+ const theirStats = computeStats({ author: cleanAuthor, since });
45
+ const myStreak = getStreakData(getCurrentUser());
46
+ const theirStreak = getStreakData(cleanAuthor);
47
+ console.log(renderComparison(myStats, theirStats, myStreak, theirStreak, repoName));
48
+ });
49
+
50
+ program
51
+ .command('team')
52
+ .description('Show all contributors ranked')
53
+ .option('-p, --period <period>', 'today, week, month, all', 'all')
54
+ .action((opts) => {
55
+ ensureRepo();
56
+ const { since, label } = getTimeRange(opts.period);
57
+ const repoName = getRepoName();
58
+ const authors = getAllAuthors({ since });
59
+ console.log(renderTeam(authors, repoName, label));
60
+ });
61
+
62
+ program
63
+ .command('card')
64
+ .description('Generate a shareable SVG card')
65
+ .option('--dark', 'Dark theme (default)')
66
+ .option('--light', 'Light theme')
67
+ .option('-p, --period <period>', 'today, week, month, all', 'all')
68
+ .option('-o, --output <file>', 'Output file', 'flex-card.svg')
69
+ .action((opts) => {
70
+ ensureRepo();
71
+ const theme = opts.light ? 'light' : 'dark';
72
+ const { since, label } = getTimeRange(opts.period);
73
+ const repoName = getRepoName();
74
+ const stats = computeStats({ since });
75
+ const streakData = getStreakData(getCurrentUser());
76
+ const svg = generateSVG(stats, streakData, repoName, label, theme);
77
+ writeFileSync(opts.output, svg);
78
+ console.log(`\u2728 Card saved to ${opts.output} (${theme} theme)`);
79
+ });
80
+
81
+ program
82
+ .command('streak')
83
+ .description('Show your commit streak')
84
+ .action(() => {
85
+ ensureRepo();
86
+ const repoName = getRepoName();
87
+ const user = getCurrentUser();
88
+ const streakData = getStreakData(user);
89
+ const stats = computeStats({});
90
+ console.log(renderStreak(streakData, stats, repoName));
91
+ });
92
+
93
+ program
94
+ .command('langs')
95
+ .description('Breakdown by language/file type')
96
+ .option('-p, --period <period>', 'today, week, month, all', 'all')
97
+ .action((opts) => {
98
+ ensureRepo();
99
+ const { since, label } = getTimeRange(opts.period);
100
+ const repoName = getRepoName();
101
+ const stats = computeStats({ since });
102
+ console.log(renderLangs(stats, repoName, label));
103
+ });
104
+
105
+ program.parse();
package/src/rank.js ADDED
@@ -0,0 +1,67 @@
1
+ export function getRank(stats, streakData) {
2
+ const titles = [];
3
+
4
+ // Time-based titles
5
+ const { peakHour, hourCounts } = stats;
6
+ const nightCommits = hourCounts.slice(22, 24).reduce((a, b) => a + b, 0)
7
+ + hourCounts.slice(0, 5).reduce((a, b) => a + b, 0);
8
+ const morningCommits = hourCounts.slice(5, 9).reduce((a, b) => a + b, 0);
9
+ const total = hourCounts.reduce((a, b) => a + b, 0) || 1;
10
+
11
+ if (nightCommits / total > 0.3) titles.push({ title: 'Night Owl', icon: '\u{1F989}', weight: 3 });
12
+ if (morningCommits / total > 0.3) titles.push({ title: 'Early Bird', icon: '\u{1F426}', weight: 3 });
13
+
14
+ // Weekend warrior
15
+ const weekendCommits = (stats.commitData || []).filter(c => {
16
+ const day = new Date(c.date).getDay();
17
+ return day === 0 || day === 6;
18
+ }).length;
19
+ if (weekendCommits / total > 0.3) titles.push({ title: 'Weekend Warrior', icon: '\u{2694}\u{FE0F}', weight: 2 });
20
+
21
+ // Message pattern titles
22
+ const messages = (stats.commitData || []).map(c => c.message.toLowerCase());
23
+ const fixCount = messages.filter(m => m.includes('fix') || m.includes('bug') || m.includes('patch')).length;
24
+ const refactorCount = messages.filter(m => m.includes('refactor') || m.includes('clean') || m.includes('restructure')).length;
25
+ const featCount = messages.filter(m => m.includes('feat') || m.includes('add') || m.includes('implement')).length;
26
+ const docsCount = messages.filter(m => m.includes('doc') || m.includes('readme') || m.includes('comment')).length;
27
+ const testCount = messages.filter(m => m.includes('test') || m.includes('spec') || m.includes('coverage')).length;
28
+
29
+ if (fixCount / total > 0.3) titles.push({ title: 'Bug Slayer', icon: '\u{1F41B}', weight: 4 });
30
+ if (refactorCount / total > 0.2) titles.push({ title: 'Refactor King', icon: '\u{1F451}', weight: 4 });
31
+ if (featCount / total > 0.3) titles.push({ title: 'Feature Factory', icon: '\u{1F3ED}', weight: 3 });
32
+ if (docsCount / total > 0.2) titles.push({ title: 'Docs Hero', icon: '\u{1F4DA}', weight: 2 });
33
+ if (testCount / total > 0.2) titles.push({ title: 'Test Guru', icon: '\u{1F9EA}', weight: 2 });
34
+
35
+ // Volume-based
36
+ if (stats.commits >= 500) titles.push({ title: 'Commit Machine', icon: '\u{1F916}', weight: 5 });
37
+ else if (stats.commits >= 100) titles.push({ title: 'Prolific Coder', icon: '\u{26A1}', weight: 3 });
38
+
39
+ if (stats.added > 10000) titles.push({ title: 'Code Cannon', icon: '\u{1F4A5}', weight: 3 });
40
+ if (stats.removed > stats.added) titles.push({ title: 'Code Surgeon', icon: '\u{1FA7A}', weight: 4 });
41
+
42
+ // Streak-based
43
+ if (streakData && streakData.current >= 30) titles.push({ title: 'Streak Legend', icon: '\u{1F525}', weight: 5 });
44
+ else if (streakData && streakData.current >= 7) titles.push({ title: 'On Fire', icon: '\u{1F525}', weight: 3 });
45
+
46
+ // Language diversity
47
+ if (stats.languages.length >= 5) titles.push({ title: 'Polyglot', icon: '\u{1F30D}', weight: 2 });
48
+
49
+ if (!titles.length) {
50
+ if (stats.commits > 0) titles.push({ title: 'Contributor', icon: '\u{1F4BB}', weight: 1 });
51
+ else titles.push({ title: 'Observer', icon: '\u{1F440}', weight: 0 });
52
+ }
53
+
54
+ titles.sort((a, b) => b.weight - a.weight);
55
+ return titles[0];
56
+ }
57
+
58
+ export function getLevel(commits) {
59
+ if (commits >= 1000) return { level: 10, name: 'Legendary', bar: '\u2588'.repeat(10) };
60
+ if (commits >= 500) return { level: 8, name: 'Master', bar: '\u2588'.repeat(8) + '\u2591'.repeat(2) };
61
+ if (commits >= 200) return { level: 6, name: 'Expert', bar: '\u2588'.repeat(6) + '\u2591'.repeat(4) };
62
+ if (commits >= 100) return { level: 5, name: 'Skilled', bar: '\u2588'.repeat(5) + '\u2591'.repeat(5) };
63
+ if (commits >= 50) return { level: 4, name: 'Adept', bar: '\u2588'.repeat(4) + '\u2591'.repeat(6) };
64
+ if (commits >= 20) return { level: 3, name: 'Apprentice', bar: '\u2588'.repeat(3) + '\u2591'.repeat(7) };
65
+ if (commits >= 5) return { level: 2, name: 'Novice', bar: '\u2588'.repeat(2) + '\u2591'.repeat(8) };
66
+ return { level: 1, name: 'Newcomer', bar: '\u2588' + '\u2591'.repeat(9) };
67
+ }
package/src/stats.js ADDED
@@ -0,0 +1,104 @@
1
+ import { getCommits, getDiffStatsAndLangs, getCurrentUser } from './git.js';
2
+
3
+ const EXT_NAMES = {
4
+ js: 'JavaScript', ts: 'TypeScript', jsx: 'React JSX', tsx: 'React TSX',
5
+ py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java',
6
+ cpp: 'C++', c: 'C', cs: 'C#', php: 'PHP', swift: 'Swift',
7
+ kt: 'Kotlin', scala: 'Scala', html: 'HTML', css: 'CSS', scss: 'SCSS',
8
+ less: 'Less', json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML',
9
+ md: 'Markdown', sql: 'SQL', sh: 'Shell', bash: 'Shell', zsh: 'Shell',
10
+ vue: 'Vue', svelte: 'Svelte', dart: 'Dart', lua: 'Lua', zig: 'Zig',
11
+ ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', hs: 'Haskell',
12
+ ml: 'OCaml', r: 'R', jl: 'Julia', tf: 'Terraform', dockerfile: 'Docker',
13
+ graphql: 'GraphQL', proto: 'Protobuf', xml: 'XML', other: 'Other',
14
+ };
15
+
16
+ export function computeStats({ author, since, until } = {}) {
17
+ const user = author || getCurrentUser();
18
+ const commits = getCommits({ author: user, since, until });
19
+
20
+ // Single git command for all diff stats + language breakdown
21
+ const { added, removed, files, fileChanges, extCount } = getDiffStatsAndLangs({ author: user, since, until });
22
+
23
+ // Language percentages (merge extensions that map to the same language name)
24
+ const totalFiles = Object.values(extCount).reduce((a, b) => a + b, 0) || 1;
25
+ const langMerged = {};
26
+ for (const [ext, count] of Object.entries(extCount)) {
27
+ const name = EXT_NAMES[ext] || ext.toUpperCase();
28
+ langMerged[name] = (langMerged[name] || 0) + count;
29
+ }
30
+ const languages = Object.entries(langMerged)
31
+ .map(([name, count]) => ({
32
+ name,
33
+ count,
34
+ pct: Math.round((count / totalFiles) * 100),
35
+ }))
36
+ .sort((a, b) => b.count - a.count)
37
+ .slice(0, 8);
38
+
39
+ // Most edited file (skip lockfiles and generated junk)
40
+ const IGNORE = /(\block\b|\.lock$|\.min\.|\.map$|\.snap$|\.d\.ts$|package-lock|yarn\.lock|pnpm-lock|uv\.lock|Cargo\.lock|Gemfile\.lock|poetry\.lock|composer\.lock|go\.sum|shrinkwrap|dist\/|build\/|\.generated\.|__pycache__|\.svg$|\.ico$|migrations\/|openapi\.|swagger\.|\.proto$|\.pb\.|schema\.prisma)/i;
41
+ const mostEdited = Object.entries(fileChanges)
42
+ .filter(([f]) => !IGNORE.test(f))
43
+ .sort((a, b) => b[1] - a[1])
44
+ .slice(0, 3);
45
+
46
+ // Peak coding hour
47
+ const hourCounts = new Array(24).fill(0);
48
+ for (const c of commits) {
49
+ const hour = new Date(c.date).getHours();
50
+ hourCounts[hour]++;
51
+ }
52
+ const peakHour = hourCounts.indexOf(Math.max(...hourCounts));
53
+
54
+ return {
55
+ author: user,
56
+ commits: commits.length,
57
+ added,
58
+ removed,
59
+ net: added - removed,
60
+ filesCount: files.size,
61
+ languages,
62
+ mostEdited: mostEdited.length ? mostEdited.map(([file, changes]) => ({ file, changes })) : null,
63
+ peakHour,
64
+ hourCounts,
65
+ commitData: commits,
66
+ };
67
+ }
68
+
69
+ export function getTimeRange(period) {
70
+ const now = new Date();
71
+ switch (period) {
72
+ case 'today': {
73
+ const d = new Date(now);
74
+ d.setHours(0, 0, 0, 0);
75
+ return { since: d.toISOString().slice(0, 10), label: 'Today' };
76
+ }
77
+ case 'week': {
78
+ const d = new Date(now);
79
+ d.setDate(d.getDate() - d.getDay());
80
+ d.setHours(0, 0, 0, 0);
81
+ return { since: d.toISOString().slice(0, 10), label: 'This Week' };
82
+ }
83
+ case 'month': {
84
+ const d = new Date(now.getFullYear(), now.getMonth(), 1);
85
+ return { since: d.toISOString().slice(0, 10), label: 'This Month' };
86
+ }
87
+ case 'all':
88
+ default:
89
+ return { since: undefined, label: 'All Time' };
90
+ }
91
+ }
92
+
93
+ export function formatHour(h) {
94
+ if (h === 0) return '12 AM';
95
+ if (h < 12) return `${h} AM`;
96
+ if (h === 12) return '12 PM';
97
+ return `${h - 12} PM`;
98
+ }
99
+
100
+ export function formatNumber(n) {
101
+ if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(1) + 'M';
102
+ if (Math.abs(n) >= 1000) return (n / 1000).toFixed(1) + 'K';
103
+ return n.toString();
104
+ }
package/src/svg.js ADDED
@@ -0,0 +1,138 @@
1
+ import { formatHour, formatNumber } from './stats.js';
2
+ import { getRank, getLevel } from './rank.js';
3
+ import { getHighlights } from './highlights.js';
4
+
5
+ const DARK = {
6
+ bg1: '#0d1117', bg2: '#161b22', border: '#30363d',
7
+ text: '#e6edf3', textDim: '#7d8590', accent: '#58a6ff',
8
+ green: '#3fb950', red: '#f85149', yellow: '#d29922',
9
+ purple: '#bc8cff', grad1: '#0d1117', grad2: '#161b22',
10
+ };
11
+
12
+ const LIGHT = {
13
+ bg1: '#ffffff', bg2: '#f6f8fa', border: '#d0d7de',
14
+ text: '#1f2328', textDim: '#656d76', accent: '#0969da',
15
+ green: '#1a7f37', red: '#cf222e', yellow: '#9a6700',
16
+ purple: '#8250df', grad1: '#ffffff', grad2: '#f6f8fa',
17
+ };
18
+
19
+ export function generateSVG(stats, streakData, repoName, periodLabel, theme = 'dark') {
20
+ const c = theme === 'light' ? LIGHT : DARK;
21
+ const rank = getRank(stats, streakData);
22
+ const level = getLevel(stats.commits);
23
+ const highlights = getHighlights(stats, streakData);
24
+ const W = 480, H = 430;
25
+
26
+ // Language bars
27
+ let langBars = '';
28
+ let langLabels = '';
29
+ let xOffset = 30;
30
+ const barW = W - 60;
31
+ for (const lang of stats.languages.slice(0, 6)) {
32
+ const w = Math.max(2, (lang.pct / 100) * barW);
33
+ const colors = ['#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#f85149', '#79c0ff'];
34
+ const color = colors[stats.languages.indexOf(lang) % colors.length];
35
+ langBars += `<rect x="${xOffset}" y="290" width="${w}" height="8" rx="2" fill="${color}"/>`;
36
+ if (lang.pct >= 8) {
37
+ langLabels += `<text x="${xOffset + w / 2}" y="316" fill="${c.textDim}" font-size="10" text-anchor="middle" font-family="'SF Mono','Fira Code',monospace">${lang.name} ${lang.pct}%</text>`;
38
+ }
39
+ xOffset += w + 2;
40
+ }
41
+
42
+ // Hour activity chart (mini sparkline)
43
+ const maxH = Math.max(...stats.hourCounts, 1);
44
+ let sparkline = '';
45
+ for (let i = 0; i < 24; i++) {
46
+ const h = (stats.hourCounts[i] / maxH) * 40;
47
+ const x = 30 + i * ((W - 60) / 24);
48
+ sparkline += `<rect x="${x}" y="${340 - h}" width="${(W - 60) / 24 - 1}" height="${h}" rx="1" fill="${c.accent}" opacity="0.6"/>`;
49
+ }
50
+
51
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
52
+ <defs>
53
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
54
+ <stop offset="0%" style="stop-color:${c.grad1}"/>
55
+ <stop offset="100%" style="stop-color:${c.grad2}"/>
56
+ </linearGradient>
57
+ <linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
58
+ <stop offset="0%" style="stop-color:${c.accent}"/>
59
+ <stop offset="100%" style="stop-color:${c.purple}"/>
60
+ </linearGradient>
61
+ <filter id="glow">
62
+ <feGaussianBlur stdDeviation="2" result="blur"/>
63
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
64
+ </filter>
65
+ </defs>
66
+
67
+ <!-- Background -->
68
+ <rect width="${W}" height="${H}" rx="12" fill="url(#bg)" stroke="${c.border}" stroke-width="1"/>
69
+
70
+ <!-- Header line -->
71
+ <rect x="0" y="54" width="${W}" height="1" fill="${c.border}"/>
72
+
73
+ <!-- Title -->
74
+ <text x="30" y="36" fill="${c.accent}" font-size="16" font-weight="bold" font-family="'SF Mono','Fira Code',monospace" filter="url(#glow)">FLEX</text>
75
+ <text x="78" y="36" fill="${c.textDim}" font-size="13" font-family="'SF Mono','Fira Code',monospace">${escXml(repoName)} \u2022 ${escXml(periodLabel)}</text>
76
+
77
+ <!-- User + Rank -->
78
+ <text x="30" y="82" fill="${c.yellow}" font-size="15" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">${escXml(stats.author)}</text>
79
+ <text x="${30 + stats.author.length * 9.5}" y="82" fill="${c.purple}" font-size="13" font-style="italic" font-family="'SF Mono','Fira Code',monospace"> ${rank.icon} ${escXml(rank.title)}</text>
80
+
81
+ <!-- Level bar -->
82
+ <text x="30" y="105" fill="${c.textDim}" font-size="11" font-family="'SF Mono','Fira Code',monospace">LVL ${level.level} \u2022 ${escXml(level.name)}</text>
83
+ <rect x="160" y="96" width="120" height="8" rx="4" fill="${c.border}"/>
84
+ <rect x="160" y="96" width="${level.level * 12}" height="8" rx="4" fill="url(#accent)"/>
85
+
86
+ <!-- Separator -->
87
+ <rect x="30" y="116" width="${W - 60}" height="1" fill="${c.border}"/>
88
+
89
+ <!-- Stats grid -->
90
+ <text x="30" y="142" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">COMMITS</text>
91
+ <text x="30" y="160" fill="${c.text}" font-size="18" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">${formatNumber(stats.commits)}</text>
92
+
93
+ <text x="140" y="142" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">ADDED</text>
94
+ <text x="140" y="160" fill="${c.green}" font-size="18" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">+${formatNumber(stats.added)}</text>
95
+
96
+ <text x="250" y="142" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">REMOVED</text>
97
+ <text x="250" y="160" fill="${c.red}" font-size="18" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">-${formatNumber(stats.removed)}</text>
98
+
99
+ <text x="370" y="142" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">FILES</text>
100
+ <text x="370" y="160" fill="${c.text}" font-size="18" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">${formatNumber(stats.filesCount)}</text>
101
+
102
+ <!-- Row 2 -->
103
+ <text x="30" y="195" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">PEAK HOUR</text>
104
+ <text x="30" y="213" fill="${c.text}" font-size="14" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">${formatHour(stats.peakHour)}</text>
105
+
106
+ <text x="140" y="195" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">STREAK</text>
107
+ <text x="140" y="213" fill="${c.yellow}" font-size="14" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">${streakData.current}d \u{1F525}</text>
108
+
109
+ <text x="250" y="195" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">BEST STREAK</text>
110
+ <text x="250" y="213" fill="${c.text}" font-size="14" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">${streakData.longest}d</text>
111
+
112
+ <text x="370" y="195" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">NET LINES</text>
113
+ <text x="370" y="213" fill="${stats.net >= 0 ? c.green : c.red}" font-size="14" font-weight="bold" font-family="'SF Mono','Fira Code',monospace">${stats.net >= 0 ? '+' : ''}${formatNumber(stats.net)}</text>
114
+
115
+ <!-- Highlights -->
116
+ ${highlights.map((h, i) => `<text x="30" y="${250 + i * 18}" fill="${c.yellow}" font-size="11" font-style="italic" font-family="'SF Mono','Fira Code',monospace">&gt; ${escXml(h)}</text>`).join('\n ')}
117
+
118
+ <!-- Language bars -->
119
+ ${langBars}
120
+ ${langLabels}
121
+
122
+ <!-- Activity sparkline -->
123
+ <text x="30" y="340" fill="${c.textDim}" font-size="10" font-family="'SF Mono','Fira Code',monospace">ACTIVITY BY HOUR</text>
124
+ ${sparkline}
125
+
126
+ <!-- Footer -->
127
+ <text x="${W - 30}" y="${H - 12}" fill="${c.textDim}" font-size="9" text-anchor="end" font-family="'SF Mono','Fira Code',monospace">generated by flex</text>
128
+ </svg>`;
129
+ }
130
+
131
+ function escXml(s) {
132
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
133
+ }
134
+
135
+ function truncPath(p, max) {
136
+ if (p.length <= max) return p;
137
+ return '...' + p.slice(-(max - 3));
138
+ }
@@ -0,0 +1,210 @@
1
+ import chalk from 'chalk';
2
+ import { formatHour, formatNumber } from './stats.js';
3
+ import { getRank, getLevel } from './rank.js';
4
+ import { getHighlights } from './highlights.js';
5
+
6
+ const BOX = { tl: '\u256D', tr: '\u256E', bl: '\u2570', br: '\u256F', h: '\u2500', v: '\u2502' };
7
+ const W = 56;
8
+
9
+ function line(left, content, right) {
10
+ const stripped = content.replace(/\x1B\[[0-9;]*m/g, '');
11
+ const pad = W - 2 - stripped.length;
12
+ return `${left}${content}${' '.repeat(Math.max(0, pad))}${right}`;
13
+ }
14
+
15
+ function box(content) {
16
+ return `${BOX.v} ${content}`;
17
+ }
18
+
19
+ function hr() {
20
+ return chalk.dim(`${BOX.v}${BOX.h.repeat(W - 2)}${BOX.v}`);
21
+ }
22
+
23
+ function padRight(str, len) {
24
+ const stripped = str.replace(/\x1B\[[0-9;]*m/g, '');
25
+ return str + ' '.repeat(Math.max(0, len - stripped.length));
26
+ }
27
+
28
+ function langBar(pct) {
29
+ const filled = Math.round(pct / 5);
30
+ return chalk.green('\u2588'.repeat(filled)) + chalk.dim('\u2591'.repeat(20 - filled));
31
+ }
32
+
33
+ export function renderCard(stats, streakData, repoName, periodLabel) {
34
+ const rank = getRank(stats, streakData);
35
+ const level = getLevel(stats.commits);
36
+ const o = [];
37
+
38
+ // Top border
39
+ o.push(chalk.cyan(`${BOX.tl}${BOX.h.repeat(W - 2)}${BOX.tr}`));
40
+
41
+ // Header
42
+ const title = ` FLEX \u2022 ${repoName}`;
43
+ const period = periodLabel;
44
+ const headerPad = W - 2 - title.length - period.length;
45
+ o.push(chalk.cyan(BOX.v) + chalk.bold.white(title) + ' '.repeat(Math.max(1, headerPad)) + chalk.dim(period) + chalk.cyan(BOX.v));
46
+
47
+ o.push(chalk.cyan(`${BOX.v}${BOX.h.repeat(W - 2)}${BOX.v}`));
48
+
49
+ // User + rank
50
+ const userLine = ` ${chalk.bold.yellow(stats.author)} ${rank.icon} ${chalk.italic.magenta(rank.title)}`;
51
+ o.push(line(chalk.cyan(BOX.v), userLine, chalk.cyan(BOX.v)));
52
+
53
+ // Level bar
54
+ const lvlLine = ` ${chalk.dim('LVL')} ${chalk.white(level.level)} ${chalk.cyan(level.bar)} ${chalk.dim(level.name)}`;
55
+ o.push(line(chalk.cyan(BOX.v), lvlLine, chalk.cyan(BOX.v)));
56
+
57
+ o.push(hr());
58
+
59
+ // Stats grid
60
+ const col1 = [
61
+ [chalk.dim('Commits'), chalk.bold.white(formatNumber(stats.commits))],
62
+ [chalk.dim('Added'), chalk.bold.green('+' + formatNumber(stats.added))],
63
+ [chalk.dim('Removed'), chalk.bold.red('-' + formatNumber(stats.removed))],
64
+ [chalk.dim('Net'), chalk.bold[stats.net >= 0 ? 'green' : 'red']((stats.net >= 0 ? '+' : '') + formatNumber(stats.net))],
65
+ ];
66
+ const col2 = [
67
+ [chalk.dim('Files'), chalk.bold.white(formatNumber(stats.filesCount))],
68
+ [chalk.dim('Peak Hour'), chalk.bold.white(formatHour(stats.peakHour))],
69
+ [chalk.dim('Streak'), chalk.bold.yellow(`${streakData.current}d \u{1F525}`)],
70
+ [chalk.dim('Top Streak'), chalk.bold.white(`${streakData.longest}d`)],
71
+ ];
72
+
73
+ for (let i = 0; i < col1.length; i++) {
74
+ const left = ` ${padRight(col1[i][0], 10)} ${padRight(col1[i][1], 10)}`;
75
+ const right = `${padRight(col2[i][0], 11)} ${col2[i][1]}`;
76
+ const full = left + ' ' + right;
77
+ o.push(line(chalk.cyan(BOX.v), full, chalk.cyan(BOX.v)));
78
+ }
79
+
80
+ // Languages
81
+ if (stats.languages.length) {
82
+ o.push(hr());
83
+ o.push(line(chalk.cyan(BOX.v), ` ${chalk.bold.white('Languages')}`, chalk.cyan(BOX.v)));
84
+ for (const lang of stats.languages.slice(0, 5)) {
85
+ const lbl = padRight(` ${chalk.white(lang.name)}`, 20);
86
+ const bar = langBar(lang.pct);
87
+ const pctStr = chalk.dim(`${lang.pct}%`);
88
+ o.push(line(chalk.cyan(BOX.v), `${lbl} ${bar} ${pctStr}`, chalk.cyan(BOX.v)));
89
+ }
90
+ }
91
+
92
+ // Highlights
93
+ const highlights = getHighlights(stats, streakData);
94
+ if (highlights.length) {
95
+ o.push(hr());
96
+ for (const h of highlights) {
97
+ o.push(line(chalk.cyan(BOX.v), ` ${chalk.yellow('>')} ${chalk.italic.white(h)}`, chalk.cyan(BOX.v)));
98
+ }
99
+ }
100
+
101
+ // Bottom
102
+ o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W - 2)}${BOX.br}`));
103
+
104
+ return o.join('\n');
105
+ }
106
+
107
+ export function renderComparison(stats1, stats2, streak1, streak2, repoName) {
108
+ const rank1 = getRank(stats1, streak1);
109
+ const rank2 = getRank(stats2, streak2);
110
+ const o = [];
111
+ const W2 = 64;
112
+
113
+ o.push(chalk.cyan(`${BOX.tl}${BOX.h.repeat(W2 - 2)}${BOX.tr}`));
114
+ o.push(line(chalk.cyan(BOX.v), chalk.bold.white(` FLEX VS \u2022 ${repoName}`), chalk.cyan(BOX.v)));
115
+ o.push(chalk.cyan(`${BOX.v}${BOX.h.repeat(W2 - 2)}${BOX.v}`));
116
+
117
+ function vsLine(label, v1, v2) {
118
+ return line(chalk.cyan(BOX.v),
119
+ ` ${padRight(chalk.dim(label), 12)} ${padRight(v1, 14)} ${chalk.dim('vs')} ${padRight(v2, 14)}`,
120
+ chalk.cyan(BOX.v));
121
+ }
122
+
123
+ // Names
124
+ o.push(vsLine('', chalk.bold.yellow(stats1.author), chalk.bold.blue(stats2.author)));
125
+ o.push(vsLine('Rank', chalk.magenta(`${rank1.icon} ${rank1.title}`), chalk.magenta(`${rank2.icon} ${rank2.title}`)));
126
+ o.push(hr());
127
+ o.push(vsLine('Commits', chalk.white(formatNumber(stats1.commits)), chalk.white(formatNumber(stats2.commits))));
128
+ o.push(vsLine('Added', chalk.green('+' + formatNumber(stats1.added)), chalk.green('+' + formatNumber(stats2.added))));
129
+ o.push(vsLine('Removed', chalk.red('-' + formatNumber(stats1.removed)), chalk.red('-' + formatNumber(stats2.removed))));
130
+ o.push(vsLine('Files', chalk.white(formatNumber(stats1.filesCount)), chalk.white(formatNumber(stats2.filesCount))));
131
+ o.push(vsLine('Streak', chalk.yellow(`${streak1.current}d`), chalk.yellow(`${streak2.current}d`)));
132
+
133
+ o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W2 - 2)}${BOX.br}`));
134
+ return o.join('\n');
135
+ }
136
+
137
+ export function renderTeam(authors, repoName, periodLabel) {
138
+ const o = [];
139
+ const W2 = 52;
140
+
141
+ o.push(chalk.cyan(`${BOX.tl}${BOX.h.repeat(W2 - 2)}${BOX.tr}`));
142
+ o.push(line(chalk.cyan(BOX.v), chalk.bold.white(` FLEX TEAM \u2022 ${repoName} \u2022 ${periodLabel}`), chalk.cyan(BOX.v)));
143
+ o.push(chalk.cyan(`${BOX.v}${BOX.h.repeat(W2 - 2)}${BOX.v}`));
144
+
145
+ const medals = ['\u{1F947}', '\u{1F948}', '\u{1F949}'];
146
+ const maxCommits = authors[0]?.commits || 1;
147
+
148
+ for (let i = 0; i < Math.min(authors.length, 15); i++) {
149
+ const a = authors[i];
150
+ const medal = medals[i] || chalk.dim(`${i + 1}.`);
151
+ const bar = chalk.green('\u2588'.repeat(Math.max(1, Math.round((a.commits / maxCommits) * 15))));
152
+ const name = padRight(chalk.white(a.name), 20);
153
+ o.push(line(chalk.cyan(BOX.v),
154
+ ` ${padRight(medal, 4)} ${name} ${bar} ${chalk.dim(a.commits)}`,
155
+ chalk.cyan(BOX.v)));
156
+ }
157
+
158
+ o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W2 - 2)}${BOX.br}`));
159
+ return o.join('\n');
160
+ }
161
+
162
+ export function renderStreak(streakData, stats, repoName) {
163
+ const o = [];
164
+ o.push(chalk.cyan(`${BOX.tl}${BOX.h.repeat(W - 2)}${BOX.tr}`));
165
+ o.push(line(chalk.cyan(BOX.v), chalk.bold.white(` FLEX STREAK \u2022 ${repoName}`), chalk.cyan(BOX.v)));
166
+ o.push(chalk.cyan(`${BOX.v}${BOX.h.repeat(W - 2)}${BOX.v}`));
167
+
168
+ o.push(line(chalk.cyan(BOX.v), ` ${chalk.dim('Author')} ${chalk.bold.yellow(stats.author)}`, chalk.cyan(BOX.v)));
169
+ o.push(line(chalk.cyan(BOX.v), ` ${chalk.dim('Current')} ${chalk.bold.yellow(`${streakData.current} days`)} ${streakData.current >= 7 ? '\u{1F525}' : ''}`, chalk.cyan(BOX.v)));
170
+ o.push(line(chalk.cyan(BOX.v), ` ${chalk.dim('Longest')} ${chalk.bold.white(`${streakData.longest} days`)} \u{1F3C6}`, chalk.cyan(BOX.v)));
171
+
172
+ // Visual streak bar
173
+ const days = Math.min(streakData.current, 30);
174
+ const bar = chalk.yellow('\u2588'.repeat(days)) + chalk.dim('\u2591'.repeat(30 - days));
175
+ o.push(line(chalk.cyan(BOX.v), ` ${bar}`, chalk.cyan(BOX.v)));
176
+
177
+ let msg = '';
178
+ if (streakData.current === 0) msg = 'Start a streak today!';
179
+ else if (streakData.current >= 30) msg = 'UNSTOPPABLE!';
180
+ else if (streakData.current >= 14) msg = 'Incredible dedication!';
181
+ else if (streakData.current >= 7) msg = 'One week strong!';
182
+ else if (streakData.current >= 3) msg = 'Building momentum...';
183
+ else msg = 'Keep it going!';
184
+ o.push(line(chalk.cyan(BOX.v), ` ${chalk.italic.dim(msg)}`, chalk.cyan(BOX.v)));
185
+
186
+ o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W - 2)}${BOX.br}`));
187
+ return o.join('\n');
188
+ }
189
+
190
+ export function renderLangs(stats, repoName, periodLabel) {
191
+ const o = [];
192
+ o.push(chalk.cyan(`${BOX.tl}${BOX.h.repeat(W - 2)}${BOX.tr}`));
193
+ o.push(line(chalk.cyan(BOX.v), chalk.bold.white(` FLEX LANGS \u2022 ${repoName} \u2022 ${periodLabel}`), chalk.cyan(BOX.v)));
194
+ o.push(chalk.cyan(`${BOX.v}${BOX.h.repeat(W - 2)}${BOX.v}`));
195
+
196
+ if (!stats.languages.length) {
197
+ o.push(line(chalk.cyan(BOX.v), chalk.dim(' No language data found'), chalk.cyan(BOX.v)));
198
+ } else {
199
+ for (const lang of stats.languages) {
200
+ const lbl = padRight(` ${chalk.bold.white(lang.name)}`, 22);
201
+ const bar = langBar(lang.pct);
202
+ const pct = padRight(chalk.yellow(`${lang.pct}%`), 6);
203
+ const cnt = chalk.dim(`(${lang.count} files)`);
204
+ o.push(line(chalk.cyan(BOX.v), `${lbl} ${bar} ${pct} ${cnt}`, chalk.cyan(BOX.v)));
205
+ }
206
+ }
207
+
208
+ o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W - 2)}${BOX.br}`));
209
+ return o.join('\n');
210
+ }