git-flex 1.0.0 → 1.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-flex",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "Show off your coding stats in style. Beautiful terminal cards, shareable SVGs, and fun highlights from your git history.",
6
6
  "main": "src/index.js",
@@ -41,6 +41,7 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "chalk": "5.6.2",
44
- "commander": "12.1.0"
44
+ "commander": "12.1.0",
45
+ "string-width": "7.2.0"
45
46
  }
46
47
  }
package/src/git.js CHANGED
@@ -86,17 +86,31 @@ export function getDiffStatsAndLangs({ author, since, until } = {}) {
86
86
  }
87
87
 
88
88
  export function getAllAuthors({ since, until } = {}) {
89
- const args = ['git', 'log', '--no-merges', '--format=%an'];
89
+ // Get commits + lines in one pass using a marker to separate commits
90
+ const args = ['git', 'log', '--no-merges', '--numstat', '--format=COMMIT:%an'];
90
91
  if (since) args.push(`--since=${since}`);
91
92
  if (until) args.push(`--until=${until}`);
92
93
  const raw = runArgs(args);
93
94
  if (!raw) return [];
94
- const counts = {};
95
- for (const name of raw.split('\n').filter(Boolean)) {
96
- counts[name] = (counts[name] || 0) + 1;
95
+
96
+ const authors = {};
97
+ let current = null;
98
+
99
+ for (const ln of raw.split('\n')) {
100
+ if (ln.startsWith('COMMIT:')) {
101
+ current = ln.slice(7);
102
+ if (!authors[current]) authors[current] = { commits: 0, added: 0, removed: 0 };
103
+ authors[current].commits++;
104
+ } else if (current && ln.includes('\t')) {
105
+ const [a, r] = ln.split('\t');
106
+ if (a === '-') continue;
107
+ authors[current].added += parseInt(a) || 0;
108
+ authors[current].removed += parseInt(r) || 0;
109
+ }
97
110
  }
98
- return Object.entries(counts)
99
- .map(([name, commits]) => ({ name, commits }))
111
+
112
+ return Object.entries(authors)
113
+ .map(([name, s]) => ({ name, commits: s.commits, added: s.added, removed: s.removed, net: s.added - s.removed }))
100
114
  .sort((a, b) => b.commits - a.commits);
101
115
  }
102
116
 
package/src/svg.js CHANGED
@@ -21,31 +21,70 @@ export function generateSVG(stats, streakData, repoName, periodLabel, theme = 'd
21
21
  const rank = getRank(stats, streakData);
22
22
  const level = getLevel(stats.commits);
23
23
  const highlights = getHighlights(stats, streakData);
24
- const W = 480, H = 430;
24
+ const W = 480;
25
+ const F = `font-family="'SF Mono','Fira Code',monospace"`;
25
26
 
26
- // Language bars
27
+ // Dynamic Y layout — each section pushes cursor down
28
+ let y = 0;
29
+
30
+ // Header: 0-54
31
+ y = 54;
32
+
33
+ // User + rank + level: 55-116
34
+ y = 116;
35
+
36
+ // Stats grid row 1: values at y+26, y+44
37
+ const statsY = y + 10; // 126 — labels
38
+ const statsValY = statsY + 18; // 144 — values
39
+
40
+ // Stats grid row 2
41
+ const stats2Y = statsValY + 25; // 169 — labels
42
+ const stats2ValY = stats2Y + 18; // 187 — values
43
+
44
+ // Highlights
45
+ y = stats2ValY + 20; // 207
46
+ const hlY = y;
47
+
48
+ // Language section
49
+ y = hlY + highlights.length * 18 + 14;
50
+ const langTitleY = y;
51
+ const langBarY = y + 14;
52
+ const langLabelY = langBarY + 20;
53
+
54
+ // Sparkline section
55
+ y = langLabelY + 16;
56
+ const sparkTitleY = y;
57
+ const sparkBaseY = sparkTitleY + 50;
58
+
59
+ // Footer
60
+ const footerY = sparkBaseY + 20;
61
+ const H = footerY + 10;
62
+
63
+ // Build language bars
27
64
  let langBars = '';
28
65
  let langLabels = '';
29
66
  let xOffset = 30;
30
67
  const barW = W - 60;
31
- for (const lang of stats.languages.slice(0, 6)) {
68
+ const langs = stats.languages.slice(0, 6);
69
+ const langColors = ['#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#f85149', '#79c0ff'];
70
+ for (let i = 0; i < langs.length; i++) {
71
+ const lang = langs[i];
32
72
  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}"/>`;
73
+ langBars += `<rect x="${xOffset}" y="${langBarY}" width="${w}" height="8" rx="2" fill="${langColors[i % langColors.length]}"/>`;
36
74
  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>`;
75
+ langLabels += `<text x="${xOffset + w / 2}" y="${langLabelY}" fill="${c.textDim}" font-size="10" text-anchor="middle" ${F}>${lang.name} ${lang.pct}%</text>`;
38
76
  }
39
77
  xOffset += w + 2;
40
78
  }
41
79
 
42
- // Hour activity chart (mini sparkline)
43
- const maxH = Math.max(...stats.hourCounts, 1);
80
+ // Build sparkline
81
+ const maxHour = Math.max(...stats.hourCounts, 1);
44
82
  let sparkline = '';
83
+ const sparkH = 40;
45
84
  for (let i = 0; i < 24; i++) {
46
- const h = (stats.hourCounts[i] / maxH) * 40;
85
+ const h = (stats.hourCounts[i] / maxHour) * sparkH;
47
86
  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"/>`;
87
+ sparkline += `<rect x="${x}" y="${sparkBaseY - h}" width="${(W - 60) / 24 - 1}" height="${h}" rx="1" fill="${c.accent}" opacity="0.6"/>`;
49
88
  }
50
89
 
51
90
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
@@ -67,72 +106,66 @@ export function generateSVG(stats, streakData, repoName, periodLabel, theme = 'd
67
106
  <!-- Background -->
68
107
  <rect width="${W}" height="${H}" rx="12" fill="url(#bg)" stroke="${c.border}" stroke-width="1"/>
69
108
 
70
- <!-- Header line -->
109
+ <!-- Header -->
71
110
  <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>
111
+ <text x="30" y="36" fill="${c.accent}" font-size="16" font-weight="bold" ${F} filter="url(#glow)">FLEX</text>
112
+ <text x="78" y="36" fill="${c.textDim}" font-size="13" ${F}>${escXml(repoName)} \u2022 ${escXml(periodLabel)}</text>
76
113
 
77
114
  <!-- 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>
115
+ <text x="30" y="82" fill="${c.yellow}" font-size="15" font-weight="bold" ${F}>${escXml(stats.author)}</text>
116
+ <text x="${30 + stats.author.length * 9.5}" y="82" fill="${c.purple}" font-size="13" font-style="italic" ${F}> ${rank.icon} ${escXml(rank.title)}</text>
80
117
 
81
118
  <!-- 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>
119
+ <text x="30" y="105" fill="${c.textDim}" font-size="11" ${F}>LVL ${level.level} \u2022 ${escXml(level.name)}</text>
83
120
  <rect x="160" y="96" width="120" height="8" rx="4" fill="${c.border}"/>
84
121
  <rect x="160" y="96" width="${level.level * 12}" height="8" rx="4" fill="url(#accent)"/>
85
122
 
86
123
  <!-- Separator -->
87
124
  <rect x="30" y="116" width="${W - 60}" height="1" fill="${c.border}"/>
88
125
 
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>
126
+ <!-- Stats row 1 -->
127
+ <text x="30" y="${statsY}" fill="${c.textDim}" font-size="10" ${F}>COMMITS</text>
128
+ <text x="30" y="${statsValY}" fill="${c.text}" font-size="18" font-weight="bold" ${F}>${formatNumber(stats.commits)}</text>
92
129
 
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>
130
+ <text x="140" y="${statsY}" fill="${c.textDim}" font-size="10" ${F}>ADDED</text>
131
+ <text x="140" y="${statsValY}" fill="${c.green}" font-size="18" font-weight="bold" ${F}>+${formatNumber(stats.added)}</text>
95
132
 
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>
133
+ <text x="250" y="${statsY}" fill="${c.textDim}" font-size="10" ${F}>REMOVED</text>
134
+ <text x="250" y="${statsValY}" fill="${c.red}" font-size="18" font-weight="bold" ${F}>-${formatNumber(stats.removed)}</text>
98
135
 
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>
136
+ <text x="370" y="${statsY}" fill="${c.textDim}" font-size="10" ${F}>FILES</text>
137
+ <text x="370" y="${statsValY}" fill="${c.text}" font-size="18" font-weight="bold" ${F}>${formatNumber(stats.filesCount)}</text>
101
138
 
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>
139
+ <!-- Stats row 2 -->
140
+ <text x="30" y="${stats2Y}" fill="${c.textDim}" font-size="10" ${F}>PEAK HOUR</text>
141
+ <text x="30" y="${stats2ValY}" fill="${c.text}" font-size="14" font-weight="bold" ${F}>${formatHour(stats.peakHour)}</text>
105
142
 
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>
143
+ <text x="140" y="${stats2Y}" fill="${c.textDim}" font-size="10" ${F}>STREAK</text>
144
+ <text x="140" y="${stats2ValY}" fill="${c.yellow}" font-size="14" font-weight="bold" ${F}>${streakData.current}d \u{1F525}</text>
108
145
 
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>
146
+ <text x="250" y="${stats2Y}" fill="${c.textDim}" font-size="10" ${F}>TOP STREAK</text>
147
+ <text x="250" y="${stats2ValY}" fill="${c.text}" font-size="14" font-weight="bold" ${F}>${streakData.longest}d</text>
111
148
 
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>
149
+ <text x="370" y="${stats2Y}" fill="${c.textDim}" font-size="10" ${F}>NET LINES</text>
150
+ <text x="370" y="${stats2ValY}" fill="${stats.net >= 0 ? c.green : c.red}" font-size="14" font-weight="bold" ${F}>${stats.net >= 0 ? '+' : ''}${formatNumber(stats.net)}</text>
114
151
 
115
152
  <!-- 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 ')}
153
+ ${highlights.map((h, i) => `<text x="30" y="${hlY + i * 18}" fill="${c.yellow}" font-size="11" font-style="italic" ${F}>&gt; ${escXml(h)}</text>`).join('\n ')}
117
154
 
118
- <!-- Language bars -->
155
+ <!-- Languages -->
156
+ <text x="30" y="${langTitleY}" fill="${c.textDim}" font-size="10" ${F}>LANGUAGES</text>
119
157
  ${langBars}
120
158
  ${langLabels}
121
159
 
122
160
  <!-- 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>
161
+ <text x="30" y="${sparkTitleY}" fill="${c.textDim}" font-size="10" ${F}>ACTIVITY BY HOUR</text>
124
162
  ${sparkline}
125
163
 
126
164
  <!-- 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>
165
+ <text x="${W - 30}" y="${footerY}" fill="${c.textDim}" font-size="9" text-anchor="end" ${F}>generated by git-flex</text>
128
166
  </svg>`;
129
167
  }
130
168
 
131
169
  function escXml(s) {
132
170
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
133
171
  }
134
-
135
- function truncPath(p, max) {
136
- if (p.length <= max) return p;
137
- return '...' + p.slice(-(max - 3));
138
- }
package/src/terminal.js CHANGED
@@ -1,28 +1,37 @@
1
1
  import chalk from 'chalk';
2
+ import stringWidth from 'string-width';
2
3
  import { formatHour, formatNumber } from './stats.js';
3
4
  import { getRank, getLevel } from './rank.js';
4
5
  import { getHighlights } from './highlights.js';
5
6
 
6
7
  const BOX = { tl: '\u256D', tr: '\u256E', bl: '\u2570', br: '\u256F', h: '\u2500', v: '\u2502' };
7
- const W = 56;
8
+ const W = 58;
8
9
 
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}`;
10
+ function line(content, width = W) {
11
+ const vis = stringWidth(content);
12
+ const pad = width - 2 - vis;
13
+ return `${chalk.cyan(BOX.v)}${content}${' '.repeat(Math.max(0, pad))}${chalk.cyan(BOX.v)}`;
13
14
  }
14
15
 
15
- function box(content) {
16
- return `${BOX.v} ${content}`;
16
+ function hr(width = W) {
17
+ return chalk.dim(`${BOX.v}${BOX.h.repeat(width - 2)}${BOX.v}`);
17
18
  }
18
19
 
19
- function hr() {
20
- return chalk.dim(`${BOX.v}${BOX.h.repeat(W - 2)}${BOX.v}`);
20
+ function top(width = W) {
21
+ return chalk.cyan(`${BOX.tl}${BOX.h.repeat(width - 2)}${BOX.tr}`);
21
22
  }
22
23
 
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));
24
+ function bot(width = W) {
25
+ return chalk.cyan(`${BOX.bl}${BOX.h.repeat(width - 2)}${BOX.br}`);
26
+ }
27
+
28
+ function sep(width = W) {
29
+ return chalk.cyan(`${BOX.v}${BOX.h.repeat(width - 2)}${BOX.v}`);
30
+ }
31
+
32
+ function pad(str, len) {
33
+ const vis = stringWidth(str);
34
+ return str + ' '.repeat(Math.max(0, len - vis));
26
35
  }
27
36
 
28
37
  function langBar(pct) {
@@ -35,24 +44,21 @@ export function renderCard(stats, streakData, repoName, periodLabel) {
35
44
  const level = getLevel(stats.commits);
36
45
  const o = [];
37
46
 
38
- // Top border
39
- o.push(chalk.cyan(`${BOX.tl}${BOX.h.repeat(W - 2)}${BOX.tr}`));
47
+ o.push(top());
40
48
 
41
49
  // Header
42
50
  const title = ` FLEX \u2022 ${repoName}`;
43
51
  const period = periodLabel;
44
- const headerPad = W - 2 - title.length - period.length;
52
+ const headerPad = W - 2 - stringWidth(title) - stringWidth(period);
45
53
  o.push(chalk.cyan(BOX.v) + chalk.bold.white(title) + ' '.repeat(Math.max(1, headerPad)) + chalk.dim(period) + chalk.cyan(BOX.v));
46
54
 
47
- o.push(chalk.cyan(`${BOX.v}${BOX.h.repeat(W - 2)}${BOX.v}`));
55
+ o.push(sep());
48
56
 
49
57
  // 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)));
58
+ o.push(line(` ${chalk.bold.yellow(stats.author)} ${rank.icon} ${chalk.italic.magenta(rank.title)}`));
52
59
 
53
60
  // 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)));
61
+ o.push(line(` ${chalk.dim('LVL')} ${chalk.white(level.level)} ${chalk.cyan(level.bar)} ${chalk.dim(level.name)}`));
56
62
 
57
63
  o.push(hr());
58
64
 
@@ -71,21 +77,21 @@ export function renderCard(stats, streakData, repoName, periodLabel) {
71
77
  ];
72
78
 
73
79
  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]}`;
80
+ const left = ` ${pad(col1[i][0], 10)} ${pad(col1[i][1], 10)}`;
81
+ const right = `${pad(col2[i][0], 11)} ${col2[i][1]}`;
76
82
  const full = left + ' ' + right;
77
- o.push(line(chalk.cyan(BOX.v), full, chalk.cyan(BOX.v)));
83
+ o.push(line(full));
78
84
  }
79
85
 
80
86
  // Languages
81
87
  if (stats.languages.length) {
82
88
  o.push(hr());
83
- o.push(line(chalk.cyan(BOX.v), ` ${chalk.bold.white('Languages')}`, chalk.cyan(BOX.v)));
89
+ o.push(line(` ${chalk.bold.white('Languages')}`));
84
90
  for (const lang of stats.languages.slice(0, 5)) {
85
- const lbl = padRight(` ${chalk.white(lang.name)}`, 20);
91
+ const lbl = pad(` ${chalk.white(lang.name)}`, 20);
86
92
  const bar = langBar(lang.pct);
87
93
  const pctStr = chalk.dim(`${lang.pct}%`);
88
- o.push(line(chalk.cyan(BOX.v), `${lbl} ${bar} ${pctStr}`, chalk.cyan(BOX.v)));
94
+ o.push(line(`${lbl} ${bar} ${pctStr}`));
89
95
  }
90
96
  }
91
97
 
@@ -94,13 +100,11 @@ export function renderCard(stats, streakData, repoName, periodLabel) {
94
100
  if (highlights.length) {
95
101
  o.push(hr());
96
102
  for (const h of highlights) {
97
- o.push(line(chalk.cyan(BOX.v), ` ${chalk.yellow('>')} ${chalk.italic.white(h)}`, chalk.cyan(BOX.v)));
103
+ o.push(line(` ${chalk.yellow('>')} ${chalk.italic.white(h)}`));
98
104
  }
99
105
  }
100
106
 
101
- // Bottom
102
- o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W - 2)}${BOX.br}`));
103
-
107
+ o.push(bot());
104
108
  return o.join('\n');
105
109
  }
106
110
 
@@ -108,71 +112,73 @@ export function renderComparison(stats1, stats2, streak1, streak2, repoName) {
108
112
  const rank1 = getRank(stats1, streak1);
109
113
  const rank2 = getRank(stats2, streak2);
110
114
  const o = [];
111
- const W2 = 64;
115
+ const CW = 64;
112
116
 
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}`));
117
+ o.push(top(CW));
118
+ o.push(line(chalk.bold.white(` FLEX VS \u2022 ${repoName}`), CW));
119
+ o.push(sep(CW));
116
120
 
117
121
  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));
122
+ return line(` ${pad(chalk.dim(label), 12)} ${pad(v1, 14)} ${chalk.dim('vs')} ${pad(v2, 14)}`, CW);
121
123
  }
122
124
 
123
- // Names
124
125
  o.push(vsLine('', chalk.bold.yellow(stats1.author), chalk.bold.blue(stats2.author)));
125
126
  o.push(vsLine('Rank', chalk.magenta(`${rank1.icon} ${rank1.title}`), chalk.magenta(`${rank2.icon} ${rank2.title}`)));
126
- o.push(hr());
127
+ o.push(hr(CW));
127
128
  o.push(vsLine('Commits', chalk.white(formatNumber(stats1.commits)), chalk.white(formatNumber(stats2.commits))));
128
129
  o.push(vsLine('Added', chalk.green('+' + formatNumber(stats1.added)), chalk.green('+' + formatNumber(stats2.added))));
129
130
  o.push(vsLine('Removed', chalk.red('-' + formatNumber(stats1.removed)), chalk.red('-' + formatNumber(stats2.removed))));
130
131
  o.push(vsLine('Files', chalk.white(formatNumber(stats1.filesCount)), chalk.white(formatNumber(stats2.filesCount))));
131
132
  o.push(vsLine('Streak', chalk.yellow(`${streak1.current}d`), chalk.yellow(`${streak2.current}d`)));
132
133
 
133
- o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W2 - 2)}${BOX.br}`));
134
+ o.push(bot(CW));
134
135
  return o.join('\n');
135
136
  }
136
137
 
137
138
  export function renderTeam(authors, repoName, periodLabel) {
138
139
  const o = [];
139
- const W2 = 52;
140
+ const TW = 76;
141
+
142
+ o.push(top(TW));
143
+ o.push(line(chalk.bold.white(` FLEX TEAM \u2022 ${repoName} \u2022 ${periodLabel}`), TW));
144
+ o.push(sep(TW));
140
145
 
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}`));
146
+ // Header row
147
+ o.push(line(` ${pad('', 4)} ${pad(chalk.dim('Name'), 18)} ${pad(chalk.dim('Commits'), 8)} ${pad(chalk.dim('Added'), 9)} ${pad(chalk.dim('Removed'), 9)} ${pad(chalk.dim('Net'), 9)}`, TW));
148
+ o.push(hr(TW));
144
149
 
145
150
  const medals = ['\u{1F947}', '\u{1F948}', '\u{1F949}'];
146
- const maxCommits = authors[0]?.commits || 1;
147
151
 
148
152
  for (let i = 0; i < Math.min(authors.length, 15); i++) {
149
153
  const a = authors[i];
150
154
  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)));
155
+ const name = pad(chalk.white(a.name), 18);
156
+ const commits = pad(chalk.bold.white(formatNumber(a.commits)), 8);
157
+ const added = pad(chalk.green('+' + formatNumber(a.added)), 9);
158
+ const removed = pad(chalk.red('-' + formatNumber(a.removed)), 9);
159
+ const net = a.net >= 0
160
+ ? chalk.green('+' + formatNumber(a.net))
161
+ : chalk.red(formatNumber(a.net));
162
+ o.push(line(` ${pad(medal, 4)} ${name} ${commits} ${added} ${removed} ${pad(net, 9)}`, TW));
156
163
  }
157
164
 
158
- o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W2 - 2)}${BOX.br}`));
165
+ o.push(bot(TW));
159
166
  return o.join('\n');
160
167
  }
161
168
 
162
169
  export function renderStreak(streakData, stats, repoName) {
163
170
  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}`));
171
+ o.push(top());
172
+ o.push(line(chalk.bold.white(` FLEX STREAK \u2022 ${repoName}`)));
173
+ o.push(sep());
167
174
 
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)));
175
+ o.push(line(` ${chalk.dim('Author')} ${chalk.bold.yellow(stats.author)}`));
176
+ o.push(line(` ${chalk.dim('Current')} ${chalk.bold.yellow(`${streakData.current} days`)} ${streakData.current >= 7 ? '\u{1F525}' : ''}`));
177
+ o.push(line(` ${chalk.dim('Longest')} ${chalk.bold.white(`${streakData.longest} days`)} \u{1F3C6}`));
171
178
 
172
- // Visual streak bar
173
179
  const days = Math.min(streakData.current, 30);
174
180
  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)));
181
+ o.push(line(` ${bar}`));
176
182
 
177
183
  let msg = '';
178
184
  if (streakData.current === 0) msg = 'Start a streak today!';
@@ -181,30 +187,31 @@ export function renderStreak(streakData, stats, repoName) {
181
187
  else if (streakData.current >= 7) msg = 'One week strong!';
182
188
  else if (streakData.current >= 3) msg = 'Building momentum...';
183
189
  else msg = 'Keep it going!';
184
- o.push(line(chalk.cyan(BOX.v), ` ${chalk.italic.dim(msg)}`, chalk.cyan(BOX.v)));
190
+ o.push(line(` ${chalk.italic.dim(msg)}`));
185
191
 
186
- o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W - 2)}${BOX.br}`));
192
+ o.push(bot());
187
193
  return o.join('\n');
188
194
  }
189
195
 
190
196
  export function renderLangs(stats, repoName, periodLabel) {
191
197
  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}`));
198
+ const LW = 62;
199
+ o.push(top(LW));
200
+ o.push(line(chalk.bold.white(` FLEX LANGS \u2022 ${repoName} \u2022 ${periodLabel}`), LW));
201
+ o.push(sep(LW));
195
202
 
196
203
  if (!stats.languages.length) {
197
- o.push(line(chalk.cyan(BOX.v), chalk.dim(' No language data found'), chalk.cyan(BOX.v)));
204
+ o.push(line(chalk.dim(' No language data found'), LW));
198
205
  } else {
199
206
  for (const lang of stats.languages) {
200
- const lbl = padRight(` ${chalk.bold.white(lang.name)}`, 22);
207
+ const lbl = pad(` ${chalk.bold.white(lang.name)}`, 16);
201
208
  const bar = langBar(lang.pct);
202
- const pct = padRight(chalk.yellow(`${lang.pct}%`), 6);
209
+ const pct = pad(chalk.yellow(`${lang.pct}%`), 5);
203
210
  const cnt = chalk.dim(`(${lang.count} files)`);
204
- o.push(line(chalk.cyan(BOX.v), `${lbl} ${bar} ${pct} ${cnt}`, chalk.cyan(BOX.v)));
211
+ o.push(line(`${lbl} ${bar} ${pct} ${cnt}`, LW));
205
212
  }
206
213
  }
207
214
 
208
- o.push(chalk.cyan(`${BOX.bl}${BOX.h.repeat(W - 2)}${BOX.br}`));
215
+ o.push(bot(LW));
209
216
  return o.join('\n');
210
217
  }