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.
@@ -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
+ });