devgrowth 1.0.0 → 1.1.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.
@@ -1,293 +1,293 @@
1
- import * as os from 'os';
2
- import * as path from 'path';
3
- import chalk from 'chalk';
4
- import * as configManager from './configManager.js';
5
- import * as schedule from './schedule.js';
6
- import * as stats from './stats.js';
7
- import * as storage from './storage.js';
8
-
9
- export const VERSION = '1.0.0';
10
- export const TAGLINE = 'Zero-friction developer growth';
11
-
12
- // Each glyph is an 8-row pixel grid, 5 columns wide. Capitals and ascenders
13
- // use all 8 rows; lowercase letters occupy the x-height rows (3-7).
14
- const GLYPHS = {
15
- d: ['....#', '....#', '....#', '.####', '#...#', '#...#', '#...#', '.####'],
16
- e: ['.....', '.....', '.....', '.###.', '#...#', '#####', '#....', '.###.'],
17
- v: ['.....', '.....', '.....', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
18
- G: ['.###.', '#...#', '#....', '#....', '#.###', '#...#', '#...#', '.####'],
19
- r: ['.....', '.....', '.....', '#.##.', '##...', '#....', '#....', '#....'],
20
- o: ['.....', '.....', '.....', '.###.', '#...#', '#...#', '#...#', '.###.'],
21
- w: ['.....', '.....', '.....', '#...#', '#...#', '#.#.#', '##.##', '#...#'],
22
- t: ['.....', '..#..', '..#..', '.###.', '..#..', '..#..', '..#..', '..###'],
23
- h: ['#....', '#....', '#....', '#.##.', '##..#', '#...#', '#...#', '#...#']
24
- };
25
-
26
- const GLYPH_WIDTH = 5;
27
- const GLYPH_GAP = 1;
28
- const NAME = 'devGrowth';
29
-
30
- // Half blocks pair two pixel rows into one text row, so 8 pixel rows render as
31
- // 4 lines. Only ▀ ▄ █ are used, which the legacy Windows console also has.
32
- function halfBlock(top, bottom) {
33
- if (top === '#' && bottom === '#') return '█';
34
- if (top === '#') return '▀';
35
- if (bottom === '#') return '▄';
36
- return ' ';
37
- }
38
-
39
- export function renderWordmark(word = NAME) {
40
- const rows = [];
41
- for (let pair = 0; pair < 4; pair++) {
42
- let line = '';
43
- for (const ch of word) {
44
- const glyph = GLYPHS[ch];
45
- if (!glyph) continue;
46
- for (let col = 0; col < GLYPH_WIDTH; col++) {
47
- line += halfBlock(glyph[pair * 2][col], glyph[pair * 2 + 1][col]);
48
- }
49
- line += ' '.repeat(GLYPH_GAP);
50
- }
51
- rows.push(line.slice(0, line.length - GLYPH_GAP));
52
- }
53
- return rows;
54
- }
55
-
56
- export const WORDMARK = renderWordmark();
57
- export const WORDMARK_WIDTH = WORDMARK[0].length;
58
-
59
- // "dev" is three glyphs wide; the rest is "Growth". Splitting here lets the
60
- // camelCase read as two tones the way the written name does.
61
- const DEV_SPLIT = 3 * (GLYPH_WIDTH + GLYPH_GAP);
62
-
63
- function colorWordmark() {
64
- return WORDMARK.map(line =>
65
- chalk.gray(line.slice(0, DEV_SPLIT)) + chalk.green(line.slice(DEV_SPLIT))
66
- );
67
- }
68
-
69
- // Stored verbatim — the fullwidth characters (_ / Y ソ #) and the ideographic
70
- // spaces (U+3000) are load-bearing alignment, not padding. Only the leading
71
- // newline is stripped: String#trimStart would also eat the U+3000 spaces that
72
- // indent the first row, shearing the shell off the body.
73
- export const TURTLE_ART = `
74
-    __  _
75
-   / ♯ # \/・_)
76
- (ソ♯ # ♯ #Y/
77
-  (_)――-(_)′`.replace(/^\r?\n/, '');
78
-
79
- export const TURTLE_ROWS = TURTLE_ART.split('\n');
80
-
81
- // Gap between the wordmark and the turtle riding beside it.
82
- const MASCOT_GAP = 2;
83
-
84
- // Measures printable width, ignoring the ANSI escapes chalk injects.
85
- const ANSI = /\x1b\[[0-9;]*m/g;
86
- export function visibleLength(str) {
87
- return str.replace(ANSI, '').length;
88
- }
89
-
90
- // Ranges the Unicode East Asian Width table marks Wide or Fullwidth, which a
91
- // terminal draws in two cells. Covers what the turtle uses: U+3000 (ideographic
92
- // space) and the fullwidth forms _ / # \ ( Y at U+FF01–FF60. Deliberately
93
- // excludes U+FF61–FFDC — the halfwidth katakana ソ and ・ occupy one cell.
94
- const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹯!-⦆¢-₩]/;
95
-
96
- /**
97
- * Terminal columns a string occupies. `visibleLength` counts code points, which
98
- * under-reports any fullwidth character — use this wherever the answer feeds a
99
- * layout decision.
100
- */
101
- export function displayWidth(str) {
102
- let cells = 0;
103
- for (const ch of str.replace(ANSI, '')) {
104
- cells += WIDE.test(ch) ? 2 : 1;
105
- }
106
- return cells;
107
- }
108
-
109
- function terminalWidth(stream = process.stdout) {
110
- return stream && stream.columns ? stream.columns : 80;
111
- }
112
-
113
- function tildify(absPath) {
114
- const home = os.homedir();
115
- const short = absPath.startsWith(home) ? `~${absPath.slice(home.length)}` : absPath;
116
- // Display with forward slashes to match the paths the other commands print.
117
- return short.split(path.sep).join('/');
118
- }
119
-
120
- export function plural(count, word) {
121
- return `${count} ${word}${count === 1 ? '' : 's'}`;
122
- }
123
-
124
- // Caps a raw (uncolored) value so one long resource name cannot blow out the
125
- // panel width. Must run before chalk wraps the string in escape codes.
126
- export function clip(str, max) {
127
- const value = String(str);
128
- return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
129
- }
130
-
131
- export function renderBar(completed, total, width = 5) {
132
- if (!total || total <= 0) return '░'.repeat(width);
133
- const ratio = Math.max(0, Math.min(1, completed / total));
134
- const filled = Math.round(ratio * width);
135
- return '█'.repeat(filled) + '░'.repeat(width - filled);
136
- }
137
-
138
- /**
139
- * Decides whether the automatic banner should be drawn. Suppressed when output
140
- * is piped or redirected so `devgrowth ... | jq` and cron-style runs stay clean.
141
- */
142
- export function shouldShowBanner(env = process.env, stream = process.stdout) {
143
- if (env.DEVGROWTH_NO_BANNER) return false;
144
- if (env.DEVGROWTH_BANNER) return true;
145
- return Boolean(stream && stream.isTTY);
146
- }
147
-
148
- /**
149
- * Reads config/stats for the info panel. Never throws — a missing config just
150
- * means DevGrowth has not been initialized yet, which the panel reports.
151
- */
152
- export async function gatherContext(now = new Date()) {
153
- const ctx = {
154
- version: VERSION,
155
- user: os.userInfo().username,
156
- host: clip(os.hostname(), 24),
157
- node: process.version,
158
- dataDir: clip(tildify(storage.getDevGrowthDir()), 32),
159
- initialized: false
160
- };
161
-
162
- let config;
163
- try {
164
- config = await configManager.getConfig();
165
- } catch {
166
- return ctx;
167
- }
168
-
169
- ctx.initialized = true;
170
- ctx.user = config.user || ctx.user;
171
-
172
- try {
173
- const today = schedule.getTodaySkill(config, now);
174
- ctx.skill = today.skill;
175
- ctx.focus = today.focus;
176
- ctx.resource = today.resource;
177
- ctx.startTime = config.session?.startTime;
178
- ctx.durationMinutes = config.session?.durationMinutes;
179
- } catch { /* a hand-edited schedule should not break the banner */ }
180
-
181
- try {
182
- const s = await stats.getStats();
183
- const weekProgress = stats.getCurrentWeekProgress(s, now);
184
- ctx.weekNumber = schedule.getWeekNumber(schedule.getSessionDate(now));
185
- ctx.weekCompleted = weekProgress.completed;
186
- ctx.weekTotal = weekProgress.total;
187
- ctx.currentStreakWeeks = s.currentStreakWeeks || 0;
188
- ctx.longestStreakWeeks = s.longestStreakWeeks || 0;
189
- ctx.totalSessions = s.totalSessions || 0;
190
- ctx.totalMinutes = s.totalMinutesDeepWork || 0;
191
- } catch { /* unreadable stats.json should not break the banner */ }
192
-
193
- return ctx;
194
- }
195
-
196
- export function buildInfoRows(ctx) {
197
- const rows = [];
198
-
199
- if (!ctx.initialized) {
200
- rows.push(['Status', chalk.yellow('not initialized')]);
201
- rows.push(['Setup', `run ${chalk.bold('devgrowth init')}`]);
202
- rows.push(['Data', chalk.gray(ctx.dataDir)]);
203
- return rows;
204
- }
205
-
206
- if (ctx.skill === 'rest') {
207
- rows.push(['Tonight', `${chalk.blue('rest day')} — system still alive`]);
208
- } else if (ctx.skill === 'review') {
209
- rows.push(['Tonight', `${chalk.blue('weekly review')} — ${ctx.focus || ''}`.trim()]);
210
- } else if (ctx.skill) {
211
- const focus = ctx.focus ? ` ${chalk.gray('—')} ${ctx.focus}` : '';
212
- rows.push(['Tonight', `${chalk.bold.cyan(ctx.skill)}${focus}`]);
213
- if (ctx.resource) rows.push(['Resource', clip(ctx.resource, 32)]);
214
- if (ctx.startTime) {
215
- rows.push(['Session', `${ctx.startTime} · ${ctx.durationMinutes ?? 20} min`]);
216
- }
217
- }
218
-
219
- if (ctx.weekCompleted !== undefined) {
220
- const bar = renderBar(ctx.weekCompleted, ctx.weekTotal);
221
- rows.push([
222
- `Week ${ctx.weekNumber}`,
223
- `${chalk.green(bar)} ${ctx.weekCompleted}/${ctx.weekTotal} sessions`
224
- ]);
225
- rows.push([
226
- 'Streak',
227
- `${chalk.bold(plural(ctx.currentStreakWeeks, 'week'))} ${chalk.gray(`(best: ${ctx.longestStreakWeeks})`)}`
228
- ]);
229
- rows.push([
230
- 'Totals',
231
- chalk.gray(`${plural(ctx.totalSessions, 'session')} · ${ctx.totalMinutes} min`)
232
- ]);
233
- }
234
-
235
- rows.push(['Data', chalk.gray(ctx.dataDir)]);
236
- return rows;
237
- }
238
-
239
- function formatRows(rows) {
240
- const labelWidth = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
241
- // Pad the raw label before colorizing — ANSI codes count toward String#padEnd.
242
- return rows.map(([label, value]) => `${chalk.bold.green(label.padEnd(labelWidth))} ${value}`);
243
- }
244
-
245
- export function renderCompact(ctx) {
246
- const name = `${chalk.gray('dev')}${chalk.bold.green('Growth')}`;
247
- const trailer = ctx.initialized && ctx.skill && ctx.skill !== 'rest' && ctx.skill !== 'review'
248
- ? `${chalk.cyan(ctx.skill)}${ctx.focus ? chalk.gray(` · ${ctx.focus}`) : ''}`
249
- : chalk.gray(TAGLINE);
250
- return `${name} ${chalk.gray(`v${ctx.version}`)} ${chalk.gray('—')} ${trailer}`;
251
- }
252
-
253
- /**
254
- * The wordmark with the turtle riding beside it. Both are four rows tall, so
255
- * the two blocks zip line-for-line. Every wordmark row is exactly
256
- * WORDMARK_WIDTH, so no padding is needed to keep the turtle's left edge flush.
257
- */
258
- export function renderHeader() {
259
- const wordmark = colorWordmark();
260
- const gap = ' '.repeat(MASCOT_GAP);
261
- return wordmark.map((line, i) => {
262
- const shell = TURTLE_ROWS[i];
263
- return shell === undefined ? line : `${line}${gap}${chalk.green(shell)}`;
264
- });
265
- }
266
-
267
- export const HEADER_WIDTH =
268
- WORDMARK_WIDTH + MASCOT_GAP + Math.max(...TURTLE_ROWS.map(displayWidth));
269
-
270
- export function renderBanner(ctx, options = {}) {
271
- const width = options.width ?? terminalWidth();
272
- const identity = ctx.user
273
- ? `${chalk.bold.green(ctx.user)}${chalk.gray('@')}${chalk.bold.green(ctx.host)} ${chalk.gray(`· v${ctx.version}`)}`
274
- : chalk.gray(`v${ctx.version}`);
275
- const body = formatRows(buildInfoRows(ctx));
276
-
277
- // Wordmark plus turtle sets the minimum width; below it the header would wrap.
278
- const widest = Math.max(HEADER_WIDTH, ...[identity, ...body].map(visibleLength));
279
- if (options.compact || width < widest) return renderCompact(ctx);
280
-
281
- const rule = chalk.gray('─'.repeat(widest));
282
- return [...renderHeader(), '', identity, rule, ...body].join('\n');
283
- }
284
-
285
- /**
286
- * Draws the banner. `force` bypasses the TTY check (used by `devgrowth banner`).
287
- */
288
- export async function printBanner(options = {}) {
289
- if (!options.force && !shouldShowBanner()) return false;
290
- const ctx = options.context ?? await gatherContext();
291
- console.log(`\n${renderBanner(ctx, options)}\n`);
292
- return true;
293
- }
1
+ import * as os from 'os';
2
+ import * as path from 'path';
3
+ import chalk from 'chalk';
4
+ import * as configManager from './configManager.js';
5
+ import * as schedule from './schedule.js';
6
+ import * as stats from './stats.js';
7
+ import * as storage from './storage.js';
8
+
9
+ export const VERSION = '1.1.0';
10
+ export const TAGLINE = 'Zero-friction developer growth';
11
+
12
+ // Each glyph is an 8-row pixel grid, 5 columns wide. Capitals and ascenders
13
+ // use all 8 rows; lowercase letters occupy the x-height rows (3-7).
14
+ const GLYPHS = {
15
+ d: ['....#', '....#', '....#', '.####', '#...#', '#...#', '#...#', '.####'],
16
+ e: ['.....', '.....', '.....', '.###.', '#...#', '#####', '#....', '.###.'],
17
+ v: ['.....', '.....', '.....', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
18
+ G: ['.###.', '#...#', '#....', '#....', '#.###', '#...#', '#...#', '.####'],
19
+ r: ['.....', '.....', '.....', '#.##.', '##...', '#....', '#....', '#....'],
20
+ o: ['.....', '.....', '.....', '.###.', '#...#', '#...#', '#...#', '.###.'],
21
+ w: ['.....', '.....', '.....', '#...#', '#...#', '#.#.#', '##.##', '#...#'],
22
+ t: ['.....', '..#..', '..#..', '.###.', '..#..', '..#..', '..#..', '..###'],
23
+ h: ['#....', '#....', '#....', '#.##.', '##..#', '#...#', '#...#', '#...#']
24
+ };
25
+
26
+ const GLYPH_WIDTH = 5;
27
+ const GLYPH_GAP = 1;
28
+ const NAME = 'devGrowth';
29
+
30
+ // Half blocks pair two pixel rows into one text row, so 8 pixel rows render as
31
+ // 4 lines. Only ▀ ▄ █ are used, which the legacy Windows console also has.
32
+ function halfBlock(top, bottom) {
33
+ if (top === '#' && bottom === '#') return '█';
34
+ if (top === '#') return '▀';
35
+ if (bottom === '#') return '▄';
36
+ return ' ';
37
+ }
38
+
39
+ export function renderWordmark(word = NAME) {
40
+ const rows = [];
41
+ for (let pair = 0; pair < 4; pair++) {
42
+ let line = '';
43
+ for (const ch of word) {
44
+ const glyph = GLYPHS[ch];
45
+ if (!glyph) continue;
46
+ for (let col = 0; col < GLYPH_WIDTH; col++) {
47
+ line += halfBlock(glyph[pair * 2][col], glyph[pair * 2 + 1][col]);
48
+ }
49
+ line += ' '.repeat(GLYPH_GAP);
50
+ }
51
+ rows.push(line.slice(0, line.length - GLYPH_GAP));
52
+ }
53
+ return rows;
54
+ }
55
+
56
+ export const WORDMARK = renderWordmark();
57
+ export const WORDMARK_WIDTH = WORDMARK[0].length;
58
+
59
+ // "dev" is three glyphs wide; the rest is "Growth". Splitting here lets the
60
+ // camelCase read as two tones the way the written name does.
61
+ const DEV_SPLIT = 3 * (GLYPH_WIDTH + GLYPH_GAP);
62
+
63
+ function colorWordmark() {
64
+ return WORDMARK.map(line =>
65
+ chalk.gray(line.slice(0, DEV_SPLIT)) + chalk.green(line.slice(DEV_SPLIT))
66
+ );
67
+ }
68
+
69
+ // Stored verbatim — the fullwidth characters (_ / Y ソ #) and the ideographic
70
+ // spaces (U+3000) are load-bearing alignment, not padding. Only the leading
71
+ // newline is stripped: String#trimStart would also eat the U+3000 spaces that
72
+ // indent the first row, shearing the shell off the body.
73
+ export const TURTLE_ART = `
74
+    __  _
75
+   / ♯ # \/・_)
76
+ (ソ♯ # ♯ #Y/
77
+  (_)――-(_)′`.replace(/^\r?\n/, '');
78
+
79
+ export const TURTLE_ROWS = TURTLE_ART.split('\n');
80
+
81
+ // Gap between the wordmark and the turtle riding beside it.
82
+ const MASCOT_GAP = 2;
83
+
84
+ // Measures printable width, ignoring the ANSI escapes chalk injects.
85
+ const ANSI = /\x1b\[[0-9;]*m/g;
86
+ export function visibleLength(str) {
87
+ return str.replace(ANSI, '').length;
88
+ }
89
+
90
+ // Ranges the Unicode East Asian Width table marks Wide or Fullwidth, which a
91
+ // terminal draws in two cells. Covers what the turtle uses: U+3000 (ideographic
92
+ // space) and the fullwidth forms _ / # \ ( Y at U+FF01–FF60. Deliberately
93
+ // excludes U+FF61–FFDC — the halfwidth katakana ソ and ・ occupy one cell.
94
+ const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹯!-⦆¢-₩]/;
95
+
96
+ /**
97
+ * Terminal columns a string occupies. `visibleLength` counts code points, which
98
+ * under-reports any fullwidth character — use this wherever the answer feeds a
99
+ * layout decision.
100
+ */
101
+ export function displayWidth(str) {
102
+ let cells = 0;
103
+ for (const ch of str.replace(ANSI, '')) {
104
+ cells += WIDE.test(ch) ? 2 : 1;
105
+ }
106
+ return cells;
107
+ }
108
+
109
+ function terminalWidth(stream = process.stdout) {
110
+ return stream && stream.columns ? stream.columns : 80;
111
+ }
112
+
113
+ function tildify(absPath) {
114
+ const home = os.homedir();
115
+ const short = absPath.startsWith(home) ? `~${absPath.slice(home.length)}` : absPath;
116
+ // Display with forward slashes to match the paths the other commands print.
117
+ return short.split(path.sep).join('/');
118
+ }
119
+
120
+ export function plural(count, word) {
121
+ return `${count} ${word}${count === 1 ? '' : 's'}`;
122
+ }
123
+
124
+ // Caps a raw (uncolored) value so one long resource name cannot blow out the
125
+ // panel width. Must run before chalk wraps the string in escape codes.
126
+ export function clip(str, max) {
127
+ const value = String(str);
128
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
129
+ }
130
+
131
+ export function renderBar(completed, total, width = 5) {
132
+ if (!total || total <= 0) return '░'.repeat(width);
133
+ const ratio = Math.max(0, Math.min(1, completed / total));
134
+ const filled = Math.round(ratio * width);
135
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
136
+ }
137
+
138
+ /**
139
+ * Decides whether the automatic banner should be drawn. Suppressed when output
140
+ * is piped or redirected so `devgrowth ... | jq` and cron-style runs stay clean.
141
+ */
142
+ export function shouldShowBanner(env = process.env, stream = process.stdout) {
143
+ if (env.DEVGROWTH_NO_BANNER) return false;
144
+ if (env.DEVGROWTH_BANNER) return true;
145
+ return Boolean(stream && stream.isTTY);
146
+ }
147
+
148
+ /**
149
+ * Reads config/stats for the info panel. Never throws — a missing config just
150
+ * means DevGrowth has not been initialized yet, which the panel reports.
151
+ */
152
+ export async function gatherContext(now = new Date()) {
153
+ const ctx = {
154
+ version: VERSION,
155
+ user: os.userInfo().username,
156
+ host: clip(os.hostname(), 24),
157
+ node: process.version,
158
+ dataDir: clip(tildify(storage.getDevGrowthDir()), 32),
159
+ initialized: false
160
+ };
161
+
162
+ let config;
163
+ try {
164
+ config = await configManager.getConfig();
165
+ } catch {
166
+ return ctx;
167
+ }
168
+
169
+ ctx.initialized = true;
170
+ ctx.user = config.user || ctx.user;
171
+
172
+ try {
173
+ const today = schedule.getTodaySkill(config, now);
174
+ ctx.skill = today.skill;
175
+ ctx.focus = today.focus;
176
+ ctx.resource = today.resource;
177
+ ctx.startTime = config.session?.startTime;
178
+ ctx.durationMinutes = config.session?.durationMinutes;
179
+ } catch { /* a hand-edited schedule should not break the banner */ }
180
+
181
+ try {
182
+ const s = await stats.getStats();
183
+ const weekProgress = stats.getCurrentWeekProgress(s, now);
184
+ ctx.weekNumber = schedule.getWeekNumber(schedule.getSessionDate(now));
185
+ ctx.weekCompleted = weekProgress.completed;
186
+ ctx.weekTotal = weekProgress.total;
187
+ ctx.currentStreakWeeks = s.currentStreakWeeks || 0;
188
+ ctx.longestStreakWeeks = s.longestStreakWeeks || 0;
189
+ ctx.totalSessions = s.totalSessions || 0;
190
+ ctx.totalMinutes = s.totalMinutesDeepWork || 0;
191
+ } catch { /* unreadable stats.json should not break the banner */ }
192
+
193
+ return ctx;
194
+ }
195
+
196
+ export function buildInfoRows(ctx) {
197
+ const rows = [];
198
+
199
+ if (!ctx.initialized) {
200
+ rows.push(['Status', chalk.yellow('not initialized')]);
201
+ rows.push(['Setup', `run ${chalk.bold('devgrowth init')}`]);
202
+ rows.push(['Data', chalk.gray(ctx.dataDir)]);
203
+ return rows;
204
+ }
205
+
206
+ if (ctx.skill === 'rest') {
207
+ rows.push(['Tonight', `${chalk.blue('rest day')} — system still alive`]);
208
+ } else if (ctx.skill === 'review') {
209
+ rows.push(['Tonight', `${chalk.blue('weekly review')} — ${ctx.focus || ''}`.trim()]);
210
+ } else if (ctx.skill) {
211
+ const focus = ctx.focus ? ` ${chalk.gray('—')} ${ctx.focus}` : '';
212
+ rows.push(['Tonight', `${chalk.bold.cyan(ctx.skill)}${focus}`]);
213
+ if (ctx.resource) rows.push(['Resource', clip(ctx.resource, 32)]);
214
+ if (ctx.startTime) {
215
+ rows.push(['Session', `${ctx.startTime} · ${ctx.durationMinutes ?? 20} min`]);
216
+ }
217
+ }
218
+
219
+ if (ctx.weekCompleted !== undefined) {
220
+ const bar = renderBar(ctx.weekCompleted, ctx.weekTotal);
221
+ rows.push([
222
+ `Week ${ctx.weekNumber}`,
223
+ `${chalk.green(bar)} ${ctx.weekCompleted}/${ctx.weekTotal} sessions`
224
+ ]);
225
+ rows.push([
226
+ 'Streak',
227
+ `${chalk.bold(plural(ctx.currentStreakWeeks, 'week'))} ${chalk.gray(`(best: ${ctx.longestStreakWeeks})`)}`
228
+ ]);
229
+ rows.push([
230
+ 'Totals',
231
+ chalk.gray(`${plural(ctx.totalSessions, 'session')} · ${ctx.totalMinutes} min`)
232
+ ]);
233
+ }
234
+
235
+ rows.push(['Data', chalk.gray(ctx.dataDir)]);
236
+ return rows;
237
+ }
238
+
239
+ function formatRows(rows) {
240
+ const labelWidth = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
241
+ // Pad the raw label before colorizing — ANSI codes count toward String#padEnd.
242
+ return rows.map(([label, value]) => `${chalk.bold.green(label.padEnd(labelWidth))} ${value}`);
243
+ }
244
+
245
+ export function renderCompact(ctx) {
246
+ const name = `${chalk.gray('dev')}${chalk.bold.green('Growth')}`;
247
+ const trailer = ctx.initialized && ctx.skill && ctx.skill !== 'rest' && ctx.skill !== 'review'
248
+ ? `${chalk.cyan(ctx.skill)}${ctx.focus ? chalk.gray(` · ${ctx.focus}`) : ''}`
249
+ : chalk.gray(TAGLINE);
250
+ return `${name} ${chalk.gray(`v${ctx.version}`)} ${chalk.gray('—')} ${trailer}`;
251
+ }
252
+
253
+ /**
254
+ * The wordmark with the turtle riding beside it. Both are four rows tall, so
255
+ * the two blocks zip line-for-line. Every wordmark row is exactly
256
+ * WORDMARK_WIDTH, so no padding is needed to keep the turtle's left edge flush.
257
+ */
258
+ export function renderHeader() {
259
+ const wordmark = colorWordmark();
260
+ const gap = ' '.repeat(MASCOT_GAP);
261
+ return wordmark.map((line, i) => {
262
+ const shell = TURTLE_ROWS[i];
263
+ return shell === undefined ? line : `${line}${gap}${chalk.green(shell)}`;
264
+ });
265
+ }
266
+
267
+ export const HEADER_WIDTH =
268
+ WORDMARK_WIDTH + MASCOT_GAP + Math.max(...TURTLE_ROWS.map(displayWidth));
269
+
270
+ export function renderBanner(ctx, options = {}) {
271
+ const width = options.width ?? terminalWidth();
272
+ const identity = ctx.user
273
+ ? `${chalk.bold.green(ctx.user)}${chalk.gray('@')}${chalk.bold.green(ctx.host)} ${chalk.gray(`· v${ctx.version}`)}`
274
+ : chalk.gray(`v${ctx.version}`);
275
+ const body = formatRows(buildInfoRows(ctx));
276
+
277
+ // Wordmark plus turtle sets the minimum width; below it the header would wrap.
278
+ const widest = Math.max(HEADER_WIDTH, ...[identity, ...body].map(visibleLength));
279
+ if (options.compact || width < widest) return renderCompact(ctx);
280
+
281
+ const rule = chalk.gray('─'.repeat(widest));
282
+ return [...renderHeader(), '', identity, rule, ...body].join('\n');
283
+ }
284
+
285
+ /**
286
+ * Draws the banner. `force` bypasses the TTY check (used by `devgrowth banner`).
287
+ */
288
+ export async function printBanner(options = {}) {
289
+ if (!options.force && !shouldShowBanner()) return false;
290
+ const ctx = options.context ?? await gatherContext();
291
+ console.log(`\n${renderBanner(ctx, options)}\n`);
292
+ return true;
293
+ }