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 +21 -0
- package/README.md +88 -0
- package/package.json +46 -0
- package/src/git.js +134 -0
- package/src/highlights.js +300 -0
- package/src/index.js +105 -0
- package/src/rank.js +67 -0
- package/src/stats.js +104 -0
- package/src/svg.js +138 -0
- package/src/terminal.js +210 -0
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
|
+

|
|
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">> ${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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function truncPath(p, max) {
|
|
136
|
+
if (p.length <= max) return p;
|
|
137
|
+
return '...' + p.slice(-(max - 3));
|
|
138
|
+
}
|
package/src/terminal.js
ADDED
|
@@ -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
|
+
}
|