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,130 @@
|
|
|
1
|
+
import * as storage from './storage.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { getWeekNumber, getWeekStart, getSessionDate } from './schedule.js';
|
|
4
|
+
|
|
5
|
+
function getStatsPath() {
|
|
6
|
+
return path.join(storage.getDevGrowthDir(), 'stats.json');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatLocalDate(date) {
|
|
10
|
+
const year = date.getFullYear();
|
|
11
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
12
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
13
|
+
return `${year}-${month}-${day}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getStats() {
|
|
17
|
+
const statsPath = getStatsPath();
|
|
18
|
+
const exists = await storage.fileExists(statsPath);
|
|
19
|
+
if (!exists) {
|
|
20
|
+
return {
|
|
21
|
+
currentStreakWeeks: 0,
|
|
22
|
+
longestStreakWeeks: 0,
|
|
23
|
+
totalSessions: 0,
|
|
24
|
+
totalMinutesDeepWork: 0,
|
|
25
|
+
currentWeek: { completed: 0, total: 5, status: 'not_started' },
|
|
26
|
+
weeklyHistory: [],
|
|
27
|
+
lastSessionDate: null,
|
|
28
|
+
lastFinalizedWeek: null
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return await storage.readJson(statsPath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function saveStats(statsData) {
|
|
35
|
+
await storage.writeJson(getStatsPath(), statsData);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function recordSession(date, skill, duration, status) {
|
|
39
|
+
const stats = await getStats();
|
|
40
|
+
const dateStr = formatLocalDate(date);
|
|
41
|
+
const weekOf = formatLocalDate(getWeekStart(date));
|
|
42
|
+
|
|
43
|
+
// Week rollover guard: if review was never run, reset progress instead of
|
|
44
|
+
// letting currentWeek.completed grow past the end of the week.
|
|
45
|
+
if (stats.currentWeek?.weekOf !== weekOf) {
|
|
46
|
+
stats.currentWeek = { completed: 0, total: 5, status: 'not_started', weekOf };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stats.totalSessions += 1;
|
|
50
|
+
if (status === 'Completed') {
|
|
51
|
+
stats.totalMinutesDeepWork += duration;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A second record on the same day (e.g. interrupted start followed by a log)
|
|
55
|
+
// counts toward totals but must not double-count the day for the week.
|
|
56
|
+
if (stats.lastSessionDate !== dateStr) {
|
|
57
|
+
stats.currentWeek.completed += 1;
|
|
58
|
+
if (stats.currentWeek.completed >= 4) {
|
|
59
|
+
stats.currentWeek.status = 'on_track';
|
|
60
|
+
} else if (stats.currentWeek.completed >= 2) {
|
|
61
|
+
stats.currentWeek.status = 'behind';
|
|
62
|
+
} else {
|
|
63
|
+
stats.currentWeek.status = 'not_started';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stats.lastSessionDate = dateStr;
|
|
68
|
+
await saveStats(stats);
|
|
69
|
+
return stats;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Current-week progress with the same rollover guard `recordSession` applies,
|
|
74
|
+
* so a week that was never finalized does not report last week's count.
|
|
75
|
+
*/
|
|
76
|
+
export function getCurrentWeekProgress(statsData, date = new Date()) {
|
|
77
|
+
const current = statsData?.currentWeek || {};
|
|
78
|
+
const total = current.total ?? 5;
|
|
79
|
+
const weekOf = formatLocalDate(getWeekStart(getSessionDate(date)));
|
|
80
|
+
if (current.weekOf && current.weekOf !== weekOf) {
|
|
81
|
+
return { completed: 0, total, status: 'not_started', weekOf };
|
|
82
|
+
}
|
|
83
|
+
return { completed: current.completed ?? 0, total, status: current.status ?? 'not_started', weekOf };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function getStatus() {
|
|
87
|
+
return await getStats();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function finalizeWeek(date) {
|
|
91
|
+
const stats = await getStats();
|
|
92
|
+
const week = getWeekNumber(date);
|
|
93
|
+
const year = date.getFullYear();
|
|
94
|
+
const weekOf = formatLocalDate(getWeekStart(date));
|
|
95
|
+
|
|
96
|
+
// Idempotency: finalizing the same week twice must not wipe the streak
|
|
97
|
+
// or append duplicate history entries.
|
|
98
|
+
if (stats.lastFinalizedWeek === weekOf) {
|
|
99
|
+
return { ...stats, justFinalized: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sessions = stats.currentWeek?.completed || 0;
|
|
103
|
+
|
|
104
|
+
// Update streak
|
|
105
|
+
if (sessions >= 4) {
|
|
106
|
+
stats.currentStreakWeeks = (stats.currentStreakWeeks || 0) + 1;
|
|
107
|
+
if (stats.currentStreakWeeks > (stats.longestStreakWeeks || 0)) {
|
|
108
|
+
stats.longestStreakWeeks = stats.currentStreakWeeks;
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
stats.currentStreakWeeks = 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Add to history
|
|
115
|
+
const historyEntry = {
|
|
116
|
+
week,
|
|
117
|
+
year,
|
|
118
|
+
sessions,
|
|
119
|
+
// Skill breakdown would need parsing logs - simplified here
|
|
120
|
+
};
|
|
121
|
+
stats.weeklyHistory = stats.weeklyHistory || [];
|
|
122
|
+
stats.weeklyHistory.push(historyEntry);
|
|
123
|
+
|
|
124
|
+
// Reset current week
|
|
125
|
+
stats.currentWeek = { completed: 0, total: 5, status: 'not_started' };
|
|
126
|
+
stats.lastFinalizedWeek = weekOf;
|
|
127
|
+
|
|
128
|
+
await saveStats(stats);
|
|
129
|
+
return { ...stats, justFinalized: true };
|
|
130
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
|
|
5
|
+
export function getDevGrowthDir() {
|
|
6
|
+
return path.join(os.homedir(), '.devgrowth');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function ensureDir(targetPath) {
|
|
10
|
+
await fs.ensureDir(targetPath);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function readJson(filePath) {
|
|
14
|
+
return await fs.readJson(filePath);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function writeJson(filePath, data) {
|
|
18
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
19
|
+
await fs.writeJson(filePath, data, { spaces: 2 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readFile(filePath) {
|
|
23
|
+
return await fs.readFile(filePath, 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeFile(filePath, content) {
|
|
27
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
28
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function fileExists(filePath) {
|
|
32
|
+
return await fs.pathExists(filePath);
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export async function start(durationSeconds, options = {}) {
|
|
2
|
+
const {
|
|
3
|
+
onTick,
|
|
4
|
+
onWarning,
|
|
5
|
+
onComplete,
|
|
6
|
+
tickInterval = 1,
|
|
7
|
+
abortSignal
|
|
8
|
+
} = options;
|
|
9
|
+
|
|
10
|
+
const durationMs = durationSeconds * 1000;
|
|
11
|
+
const startTime = Date.now();
|
|
12
|
+
const warningTime = durationMs - 60000; // 1 minute warning if >= 1 min
|
|
13
|
+
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
let interval;
|
|
17
|
+
let completionTimeout;
|
|
18
|
+
|
|
19
|
+
function cleanup() {
|
|
20
|
+
clearInterval(interval);
|
|
21
|
+
clearTimeout(completionTimeout);
|
|
22
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
23
|
+
if (abortSignal) {
|
|
24
|
+
abortSignal.removeEventListener('abort', abortHandler);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function settle(value) {
|
|
29
|
+
if (settled) return;
|
|
30
|
+
settled = true;
|
|
31
|
+
cleanup();
|
|
32
|
+
resolve(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function abortHandler() {
|
|
36
|
+
settle({ completed: false, interrupted: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sigintHandler() {
|
|
40
|
+
settle({ completed: false, interrupted: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (abortSignal) {
|
|
44
|
+
if (abortSignal.aborted) {
|
|
45
|
+
settle({ completed: false, interrupted: true });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
abortSignal.addEventListener('abort', abortHandler);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
process.once('SIGINT', sigintHandler);
|
|
52
|
+
|
|
53
|
+
interval = setInterval(() => {
|
|
54
|
+
const elapsed = Date.now() - startTime;
|
|
55
|
+
const remaining = Math.max(0, durationMs - elapsed);
|
|
56
|
+
|
|
57
|
+
if (onTick) {
|
|
58
|
+
onTick(Math.ceil(remaining / 1000));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (onWarning && durationMs >= 60000 && elapsed >= warningTime && elapsed < warningTime + tickInterval * 1000) {
|
|
62
|
+
onWarning();
|
|
63
|
+
}
|
|
64
|
+
}, tickInterval * 1000);
|
|
65
|
+
|
|
66
|
+
// Ensure the timer resolves exactly when the duration elapses
|
|
67
|
+
completionTimeout = setTimeout(() => {
|
|
68
|
+
if (onComplete) onComplete();
|
|
69
|
+
settle({ completed: true, interrupted: false });
|
|
70
|
+
}, durationMs);
|
|
71
|
+
});
|
|
72
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { initCommand } from './commands/init.js';
|
|
3
|
+
import { startCommand } from './commands/start.js';
|
|
4
|
+
import { logCommand } from './commands/log.js';
|
|
5
|
+
import { statusCommand } from './commands/status.js';
|
|
6
|
+
import { reviewCommand } from './commands/review.js';
|
|
7
|
+
import { milestoneCommand } from './commands/milestone.js';
|
|
8
|
+
import { historyCommand } from './commands/history.js';
|
|
9
|
+
import { configCommand } from './commands/config.js';
|
|
10
|
+
import { notifyCommand } from './commands/notify.js';
|
|
11
|
+
import { printBanner } from './core/banner.js';
|
|
12
|
+
|
|
13
|
+
export const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('devgrowth')
|
|
17
|
+
.description('Zero-friction developer growth CLI')
|
|
18
|
+
.version('1.0.0');
|
|
19
|
+
|
|
20
|
+
// Bare `devgrowth` is the splash screen: banner, then the command list.
|
|
21
|
+
program.action(async () => {
|
|
22
|
+
await printBanner();
|
|
23
|
+
program.outputHelp();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command('banner')
|
|
28
|
+
.description('Show the DevGrowth banner and current stats')
|
|
29
|
+
.option('--compact', 'Render the single-line banner')
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
try {
|
|
32
|
+
await printBanner({ force: true, compact: options.compact });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('Error:', err.message);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
program
|
|
40
|
+
.command('init')
|
|
41
|
+
.description('Initialize DevGrowth configuration and directories')
|
|
42
|
+
.action(async () => {
|
|
43
|
+
try {
|
|
44
|
+
await initCommand();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error('Error:', err.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.command('start')
|
|
53
|
+
.description('Start tonight\'s session')
|
|
54
|
+
.option('--light', 'Skip timer for low-energy sessions')
|
|
55
|
+
.option('--no-banner', 'Skip the startup banner')
|
|
56
|
+
.action(async (options) => {
|
|
57
|
+
try {
|
|
58
|
+
await startCommand(options);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error('Error:', err.message);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
program
|
|
66
|
+
.command('log <message>')
|
|
67
|
+
.description('Write your 2-sentence session log')
|
|
68
|
+
.option('--light', 'Mark as light session')
|
|
69
|
+
.action(async (message, options) => {
|
|
70
|
+
try {
|
|
71
|
+
await logCommand(message, options);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error('Error:', err.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
program
|
|
79
|
+
.command('status')
|
|
80
|
+
.description('Show this week\'s progress and streak')
|
|
81
|
+
.action(async () => {
|
|
82
|
+
try {
|
|
83
|
+
await statusCommand();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error('Error:', err.message);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
program
|
|
91
|
+
.command('review')
|
|
92
|
+
.description('Review this week and finalize streak')
|
|
93
|
+
.action(async () => {
|
|
94
|
+
try {
|
|
95
|
+
await reviewCommand();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error('Error:', err.message);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
program
|
|
103
|
+
.command('milestone <subcommand> [args...]')
|
|
104
|
+
.description('Milestone tracker: list, check, progress')
|
|
105
|
+
.action(async (subcommand, args) => {
|
|
106
|
+
try {
|
|
107
|
+
await milestoneCommand(subcommand, args || []);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error('Error:', err.message);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
program
|
|
115
|
+
.command('history')
|
|
116
|
+
.description('Show session history')
|
|
117
|
+
.option('--week <number>', 'Show specific week')
|
|
118
|
+
.option('--year <number>', 'Year for --week (defaults to current year)')
|
|
119
|
+
.option('--skill <name>', 'Filter by skill')
|
|
120
|
+
.option('--search <query>', 'Full-text search across logs')
|
|
121
|
+
.action(async (options) => {
|
|
122
|
+
try {
|
|
123
|
+
await historyCommand(options);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error('Error:', err.message);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
program
|
|
131
|
+
.command('config <subcommand> [args...]')
|
|
132
|
+
.description('Configuration: show, set, edit')
|
|
133
|
+
.action(async (subcommand, args) => {
|
|
134
|
+
try {
|
|
135
|
+
await configCommand(subcommand, args || []);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.error('Error:', err.message);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
program
|
|
143
|
+
.command('notify')
|
|
144
|
+
.description('Send tonight\'s desktop notification (internal use)')
|
|
145
|
+
.action(async () => {
|
|
146
|
+
try {
|
|
147
|
+
await notifyCommand();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error('Error:', err.message);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
});
|