devgrowth 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 +90 -0
- package/bin/devgrowth.js +3 -0
- package/package.json +48 -0
- package/scripts/install-windows.ps1 +28 -0
- package/src/commands/config.js +50 -0
- package/src/commands/history.js +73 -0
- package/src/commands/init.js +31 -0
- package/src/commands/log.js +36 -0
- package/src/commands/milestone.js +51 -0
- package/src/commands/notify.js +23 -0
- package/src/commands/review.js +57 -0
- package/src/commands/start.js +91 -0
- package/src/commands/status.js +53 -0
- package/src/core/banner.js +293 -0
- package/src/core/browser.js +6 -0
- package/src/core/configManager.js +70 -0
- package/src/core/logger.js +62 -0
- package/src/core/milestones.js +72 -0
- package/src/core/notifier.js +18 -0
- package/src/core/schedule.js +70 -0
- package/src/core/stats.js +130 -0
- package/src/core/storage.js +33 -0
- package/src/core/timer.js +72 -0
- package/src/index.js +152 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as storage from './storage.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
version: '1.0.0',
|
|
6
|
+
user: 'Saw Eh Doh Wah',
|
|
7
|
+
schedule: {
|
|
8
|
+
mon: { skill: 'laravel', resource: 'Laravel Daily', url: 'https://youtube.com/@LaravelDaily', focus: 'Deepen' },
|
|
9
|
+
tue: { skill: 'vue', resource: 'Vue School', url: 'https://vueschool.io', focus: 'Sharpen' },
|
|
10
|
+
wed: { skill: 'rust', resource: 'The Rust Book',url: 'https://doc.rust-lang.org/book', focus: 'Start' },
|
|
11
|
+
thu: { skill: 'laravel', resource: 'Laravel Daily', url: 'https://youtube.com/@LaravelDaily', focus: 'Apply to HRMS' },
|
|
12
|
+
fri: { skill: 'vue', resource: 'Vue School', url: 'https://vueschool.io', focus: 'Build component' },
|
|
13
|
+
sat: { skill: 'review', resource: null, url: null, focus: 'Weekly review' },
|
|
14
|
+
sun: { skill: 'rest', resource: null, url: null, focus: 'Full off day' }
|
|
15
|
+
},
|
|
16
|
+
session: {
|
|
17
|
+
startTime: '21:00',
|
|
18
|
+
durationMinutes: 20,
|
|
19
|
+
warmupMinutes: 5,
|
|
20
|
+
logMinutes: 5,
|
|
21
|
+
lateNightMode: true
|
|
22
|
+
},
|
|
23
|
+
notification: {
|
|
24
|
+
enabled: true,
|
|
25
|
+
reminderMinutesBefore: 5
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function createDefaultConfig() {
|
|
30
|
+
const configPath = path.join(storage.getDevGrowthDir(), 'config.json');
|
|
31
|
+
await storage.writeJson(configPath, DEFAULT_CONFIG);
|
|
32
|
+
return DEFAULT_CONFIG;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function getConfig() {
|
|
36
|
+
const configPath = path.join(storage.getDevGrowthDir(), 'config.json');
|
|
37
|
+
const exists = await storage.fileExists(configPath);
|
|
38
|
+
if (!exists) {
|
|
39
|
+
throw new Error('Config not found. Run `devgrowth init` first.');
|
|
40
|
+
}
|
|
41
|
+
return await storage.readJson(configPath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function updateConfig(updates) {
|
|
45
|
+
const config = await getConfig();
|
|
46
|
+
const merged = { ...config, ...updates };
|
|
47
|
+
// Deep merge for nested objects (simple version)
|
|
48
|
+
for (const key of Object.keys(updates)) {
|
|
49
|
+
if (typeof updates[key] === 'object' && !Array.isArray(updates[key]) && updates[key] !== null) {
|
|
50
|
+
merged[key] = { ...config[key], ...updates[key] };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const configPath = path.join(storage.getDevGrowthDir(), 'config.json');
|
|
54
|
+
await storage.writeJson(configPath, merged);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function setConfigValue(keyPath, value) {
|
|
58
|
+
const config = JSON.parse(JSON.stringify(await getConfig()));
|
|
59
|
+
const keys = keyPath.split('.');
|
|
60
|
+
let target = config;
|
|
61
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
62
|
+
if (typeof target[keys[i]] !== 'object' || target[keys[i]] === null) {
|
|
63
|
+
throw new Error(`Invalid key path: "${keyPath}" — "${keys[i]}" is not an object.`);
|
|
64
|
+
}
|
|
65
|
+
target = target[keys[i]];
|
|
66
|
+
}
|
|
67
|
+
target[keys[keys.length - 1]] = value;
|
|
68
|
+
const configPath = path.join(storage.getDevGrowthDir(), 'config.json');
|
|
69
|
+
await storage.writeJson(configPath, config);
|
|
70
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as storage from './storage.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { formatDate as scheduleFormatDate, getWeekStart, getWeekFileName } from './schedule.js';
|
|
4
|
+
|
|
5
|
+
function formatTime(date) {
|
|
6
|
+
return date.toTimeString().slice(0, 5);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function writeLogEntry(date, skill, resource, duration, status, message) {
|
|
10
|
+
// Key the file by the week's Monday so a week straddling a month or year
|
|
11
|
+
// boundary stays in a single file (e.g. Mon Jul 27 – Sun Aug 2 → 2026/07).
|
|
12
|
+
const weekStart = getWeekStart(date);
|
|
13
|
+
const year = weekStart.getFullYear();
|
|
14
|
+
const month = String(weekStart.getMonth() + 1).padStart(2, '0');
|
|
15
|
+
const weekFile = getWeekFileName(date);
|
|
16
|
+
const filePath = path.join(storage.getDevGrowthDir(), 'logs', String(year), month, weekFile);
|
|
17
|
+
|
|
18
|
+
const exists = await storage.fileExists(filePath);
|
|
19
|
+
let content = '';
|
|
20
|
+
|
|
21
|
+
if (!exists) {
|
|
22
|
+
const weekEnd = new Date(weekStart);
|
|
23
|
+
weekEnd.setDate(weekEnd.getDate() + 6);
|
|
24
|
+
content = `# DevGrowth Log — ${getWeekFileName(date).replace('.md', '')} (${scheduleFormatDate(weekStart)} – ${scheduleFormatDate(weekEnd)}, ${year})\n\n`;
|
|
25
|
+
} else {
|
|
26
|
+
content = await storage.readFile(filePath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for duplicate log warning
|
|
30
|
+
const dayHeader = `## ${scheduleFormatDate(date)}`;
|
|
31
|
+
if (content.includes(dayHeader)) {
|
|
32
|
+
console.warn(`⚠️ Warning: A log for ${scheduleFormatDate(date)} already exists. Appending anyway.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const statusEmoji = status === 'Completed' ? '✅' : status === 'Light' ? '🟡' : '⏹️';
|
|
36
|
+
const entry = `## ${scheduleFormatDate(date)}\n- **Skill:** ${skill}\n- **Resource:** ${resource || 'N/A'}\n- **Started:** ${formatTime(date)}\n- **Timer:** ${duration} min\n- **Status:** ${statusEmoji} ${status}\n- **Log:** ${message}\n\n`;
|
|
37
|
+
|
|
38
|
+
content += entry;
|
|
39
|
+
await storage.writeFile(filePath, content);
|
|
40
|
+
return filePath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readWeekLogs(year, month, weekFileName) {
|
|
44
|
+
const filePath = path.join(storage.getDevGrowthDir(), 'logs', String(year), month, weekFileName);
|
|
45
|
+
const exists = await storage.fileExists(filePath);
|
|
46
|
+
if (!exists) return '';
|
|
47
|
+
return await storage.readFile(filePath);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseLogEntries(logs) {
|
|
51
|
+
const entries = [];
|
|
52
|
+
const blocks = logs.split(/\n(?=## )/);
|
|
53
|
+
for (const block of blocks) {
|
|
54
|
+
const dateMatch = block.match(/^## (.+)\n/);
|
|
55
|
+
if (!dateMatch) continue;
|
|
56
|
+
const date = dateMatch[1].trim();
|
|
57
|
+
const messageMatch = block.match(/- \*\*Log:\*\* (.+)/);
|
|
58
|
+
const message = messageMatch ? messageMatch[1].trim() : '';
|
|
59
|
+
entries.push({ date, message });
|
|
60
|
+
}
|
|
61
|
+
return entries;
|
|
62
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as storage from './storage.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_MILESTONES = {
|
|
5
|
+
laravel: {
|
|
6
|
+
m1: { title: 'Know it deeper', items: { formRequests: false, policies: false, featureTest: false, refactorService: false } },
|
|
7
|
+
m2: { title: 'Build confidently', items: { queuedJob: false, observer: false, tests3: false, redisCache: false } },
|
|
8
|
+
m3: { title: 'Own the stack', items: { pipeline: false, package: false, explainArch: false, tests30: false } }
|
|
9
|
+
},
|
|
10
|
+
vue: {
|
|
11
|
+
m1: { title: 'Composition solid', items: { scriptSetup: false, coreApis: false, composable: false, noOptionsApi: false } },
|
|
12
|
+
m2: { title: 'State & patterns', items: { pinia: false, asyncHandling: false, composables3: false, lazyLoad: false } },
|
|
13
|
+
m3: { title: 'Performance aware', items: { memoryLeak: false, treeShake: false, unitTests5: false, teachJunior: false } }
|
|
14
|
+
},
|
|
15
|
+
rust: {
|
|
16
|
+
m1: { title: 'Survive the compiler', items: { bookCh6: false, ownership: false, helloWorld: false, fix10Errors: false } },
|
|
17
|
+
m2: { title: 'Think in Rust', items: { bookCh13: false, rustlings10: false, errorHandling: false, explainBorrowing: false } },
|
|
18
|
+
m3: { title: 'Ship something small', items: { cliTool: false, traits: false, fileIO: false, githubPush: false } }
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function getMilestonesPath() {
|
|
23
|
+
return path.join(storage.getDevGrowthDir(), 'milestones.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getMilestones() {
|
|
27
|
+
const milestonesPath = getMilestonesPath();
|
|
28
|
+
const exists = await storage.fileExists(milestonesPath);
|
|
29
|
+
if (!exists) {
|
|
30
|
+
return JSON.parse(JSON.stringify(DEFAULT_MILESTONES));
|
|
31
|
+
}
|
|
32
|
+
return await storage.readJson(milestonesPath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function saveMilestones(data) {
|
|
36
|
+
await storage.writeJson(getMilestonesPath(), data);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function checkMilestone(skill, month, item) {
|
|
40
|
+
const milestones = await getMilestones();
|
|
41
|
+
if (!milestones[skill]) {
|
|
42
|
+
throw new Error(`Unknown skill: ${skill}`);
|
|
43
|
+
}
|
|
44
|
+
const monthKey = month.toString().toLowerCase();
|
|
45
|
+
if (!milestones[skill][monthKey]) {
|
|
46
|
+
throw new Error(`Invalid month: ${month}`);
|
|
47
|
+
}
|
|
48
|
+
const items = milestones[skill][monthKey].items;
|
|
49
|
+
if (!(item in items)) {
|
|
50
|
+
throw new Error(`Invalid item: ${item}`);
|
|
51
|
+
}
|
|
52
|
+
items[item] = true;
|
|
53
|
+
await saveMilestones(milestones);
|
|
54
|
+
return milestones[skill];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getProgress(skill) {
|
|
58
|
+
const milestones = await getMilestones();
|
|
59
|
+
if (!milestones[skill]) {
|
|
60
|
+
throw new Error(`Unknown skill: ${skill}`);
|
|
61
|
+
}
|
|
62
|
+
let total = 0;
|
|
63
|
+
let completed = 0;
|
|
64
|
+
for (const month of Object.values(milestones[skill])) {
|
|
65
|
+
for (const value of Object.values(month.items)) {
|
|
66
|
+
total += 1;
|
|
67
|
+
if (value) completed += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
71
|
+
return { total, completed, percentage };
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import notifier from 'node-notifier';
|
|
2
|
+
|
|
3
|
+
export async function send(title, message) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
notifier.notify(
|
|
6
|
+
{
|
|
7
|
+
title,
|
|
8
|
+
message,
|
|
9
|
+
sound: false,
|
|
10
|
+
wait: false
|
|
11
|
+
},
|
|
12
|
+
(err) => {
|
|
13
|
+
if (err) reject(err);
|
|
14
|
+
else resolve();
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const DAY_MAP = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
2
|
+
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
3
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
4
|
+
|
|
5
|
+
export function formatDate(date) {
|
|
6
|
+
return `${DAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatShortDate(date) {
|
|
10
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getWeekStart(date) {
|
|
14
|
+
const d = new Date(date);
|
|
15
|
+
const day = d.getDay();
|
|
16
|
+
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
17
|
+
d.setDate(diff);
|
|
18
|
+
d.setHours(0, 0, 0, 0);
|
|
19
|
+
return d;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getWeekFileName(date) {
|
|
23
|
+
const week = getWeekNumber(date);
|
|
24
|
+
return `week${week}.md`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getSessionDate(date = new Date()) {
|
|
28
|
+
const hours = date.getHours();
|
|
29
|
+
// After midnight (00:00 - 04:00) counts as the previous day's session
|
|
30
|
+
return (hours >= 0 && hours < 4)
|
|
31
|
+
? new Date(date.getTime() - 24 * 60 * 60 * 1000)
|
|
32
|
+
: date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getTodaySkill(config, date = new Date()) {
|
|
36
|
+
const sessionDate = getSessionDate(date);
|
|
37
|
+
const dayIndex = sessionDate.getDay();
|
|
38
|
+
const dayKey = DAY_MAP[dayIndex];
|
|
39
|
+
const session = config.schedule[dayKey];
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
day: dayKey,
|
|
43
|
+
...session
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getWeekBounds(date) {
|
|
48
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
49
|
+
const day = d.getUTCDay();
|
|
50
|
+
const diff = d.getUTCDate() - day + (day === 0 ? -6 : 1); // adjust when day is Sunday
|
|
51
|
+
const start = new Date(d.setUTCDate(diff));
|
|
52
|
+
start.setUTCHours(0, 0, 0, 0);
|
|
53
|
+
const end = new Date(start);
|
|
54
|
+
end.setUTCDate(start.getUTCDate() + 6);
|
|
55
|
+
end.setUTCHours(23, 59, 59, 999);
|
|
56
|
+
return { start, end };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getWeekNumber(date) {
|
|
60
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
61
|
+
const dayNum = d.getUTCDay() || 7;
|
|
62
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
63
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
64
|
+
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getCurrentWeekFileName(date) {
|
|
68
|
+
const week = getWeekNumber(date);
|
|
69
|
+
return `week${week}.md`;
|
|
70
|
+
}
|