devgrowth 1.0.1 → 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,70 +1 @@
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
- }
1
+ export * from 'devgrowth-core/schedule';
package/src/core/stats.js CHANGED
@@ -1,130 +1,43 @@
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
- }
1
+ import * as storage from './storage.js';
2
+ import * as path from 'path';
3
+ import * as derive from 'devgrowth-core/derive';
4
+
5
+ // The guard logic (week rollover, same-day dedupe, finalize idempotency) lives
6
+ // in devgrowth-core's pure reducers so the dashboard replays identical rules.
7
+ // This module only loads, reduces and saves.
8
+ export const getCurrentWeekProgress = derive.getCurrentWeekProgress;
9
+
10
+ function getStatsPath() {
11
+ return path.join(storage.getDevGrowthDir(), 'stats.json');
12
+ }
13
+
14
+ export async function getStats() {
15
+ const statsPath = getStatsPath();
16
+ const exists = await storage.fileExists(statsPath);
17
+ if (!exists) {
18
+ return derive.emptyStats();
19
+ }
20
+ return await storage.readJson(statsPath);
21
+ }
22
+
23
+ export async function saveStats(statsData) {
24
+ await storage.writeJson(getStatsPath(), statsData);
25
+ }
26
+
27
+ export async function recordSession(date, skill, duration, status) {
28
+ const stats = derive.applySession(await getStats(), { ...derive.sessionKeysFor(date), duration, status });
29
+ await saveStats(stats);
30
+ return stats;
31
+ }
32
+
33
+ export async function getStatus() {
34
+ return await getStats();
35
+ }
36
+
37
+ export async function finalizeWeek(date) {
38
+ const { stats, justFinalized } = derive.applyReview(await getStats(), derive.reviewKeysFor(date));
39
+ if (justFinalized) {
40
+ await saveStats(stats);
41
+ }
42
+ return { ...stats, justFinalized };
43
+ }
@@ -1,33 +1,52 @@
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
- }
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, { mode } = {}) {
18
+ await fs.ensureDir(path.dirname(filePath));
19
+ await fs.writeJson(filePath, data, { spaces: 2, ...(mode ? { mode } : {}) });
20
+ // writeJson's mode only applies when the file is created; enforce it on overwrite too.
21
+ if (mode) await fs.chmod(filePath, mode);
22
+ }
23
+
24
+ export async function readFile(filePath) {
25
+ return await fs.readFile(filePath, 'utf-8');
26
+ }
27
+
28
+ export async function writeFile(filePath, content) {
29
+ await fs.ensureDir(path.dirname(filePath));
30
+ await fs.writeFile(filePath, content, 'utf-8');
31
+ }
32
+
33
+ export async function fileExists(filePath) {
34
+ return await fs.pathExists(filePath);
35
+ }
36
+
37
+ export async function appendFile(filePath, content) {
38
+ await fs.ensureDir(path.dirname(filePath));
39
+ await fs.appendFile(filePath, content, 'utf-8');
40
+ }
41
+
42
+ export async function removePath(targetPath) {
43
+ await fs.remove(targetPath);
44
+ }
45
+
46
+ export async function copyPath(src, dest, { filter } = {}) {
47
+ await fs.copy(src, dest, filter ? { filter } : {});
48
+ }
49
+
50
+ export async function listDir(dirPath) {
51
+ return (await fs.pathExists(dirPath)) ? await fs.readdir(dirPath) : [];
52
+ }
@@ -0,0 +1,101 @@
1
+ import chalk from 'chalk';
2
+ import * as derive from 'devgrowth-core/derive';
3
+ import * as auth from './auth.js';
4
+ import * as apiClient from './apiClient.js';
5
+ import * as eventLog from './eventLog.js';
6
+ import * as configManager from './configManager.js';
7
+ import * as rebuild from './rebuild.js';
8
+
9
+ export const PUSH_BATCH = 500;
10
+
11
+ /**
12
+ * `config.json` can change without going through a command (`config edit`
13
+ * opens your editor and returns, or you edit the file by hand). If it no longer
14
+ * matches the config derived from the event log, record it as a new snapshot.
15
+ * Must run before pulling, while the file still reflects the last rebuild.
16
+ */
17
+ async function recordConfigDrift() {
18
+ let local;
19
+ try {
20
+ local = await configManager.getConfig();
21
+ } catch {
22
+ return false; // not initialized yet
23
+ }
24
+ const synced = derive.deriveState(await eventLog.readAllEvents()).config;
25
+ if (synced && JSON.stringify(synced) === JSON.stringify(local)) return false;
26
+ try {
27
+ await eventLog.recordEvent('config_snapshot', { config: local });
28
+ return true;
29
+ } catch {
30
+ return false; // a hand-broken config (e.g. no schedule) is not worth blocking sync over
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Push queued events, then pull everything new and rebuild local state if
36
+ * anything arrived. Throws on network or server errors; progress made before
37
+ * the error (pushed batches, pulled pages) is kept.
38
+ */
39
+ export async function sync({ timeoutMs = 10000, detectConfigDrift = true } = {}) {
40
+ const session = await auth.getAuth();
41
+ if (!session) throw new Error('Not logged in. Run `devgrowth login` first.');
42
+ const call = (method, apiPath, body) =>
43
+ apiClient.request(session.apiUrl, method, apiPath, { token: session.token, body, timeoutMs });
44
+
45
+ if (detectConfigDrift) await recordConfigDrift();
46
+
47
+ const state = await eventLog.getSyncState();
48
+ let pushed = 0;
49
+ if (state.outbox.length > 0) {
50
+ const byId = new Map((await eventLog.readAllEvents()).map(e => [e.id, e]));
51
+ // An outbox id without an event (log damaged by hand) can never be pushed; drop it.
52
+ state.outbox = state.outbox.filter(id => byId.has(id));
53
+ const pending = state.outbox.map(id => byId.get(id));
54
+ for (let i = 0; i < pending.length; i += PUSH_BATCH) {
55
+ const batch = pending.slice(i, i + PUSH_BATCH)
56
+ .map(({ id, type, v, occurredAt, payload }) => ({ id, type, v, occurredAt, payload }));
57
+ const result = await call('POST', '/v1/events', { events: batch });
58
+ const done = new Set([...result.accepted, ...result.duplicates]);
59
+ pushed += result.accepted.length;
60
+ state.outbox = state.outbox.filter(id => !done.has(id));
61
+ await eventLog.saveSyncState(state);
62
+ }
63
+ }
64
+
65
+ const fresh = [];
66
+ let hasMore = true;
67
+ while (hasMore) {
68
+ const page = await call('GET', `/v1/events?since=${state.cursor}`);
69
+ fresh.push(...await eventLog.mergeRemoteEvents(page.events));
70
+ state.cursor = page.nextCursor;
71
+ hasMore = page.hasMore;
72
+ await eventLog.saveSyncState(state);
73
+ }
74
+
75
+ if (fresh.length > 0) await rebuild.rebuild(fresh);
76
+
77
+ state.lastSyncAt = new Date().toISOString();
78
+ await eventLog.saveSyncState(state);
79
+ return { pushed, pulled: fresh.length };
80
+ }
81
+
82
+ /**
83
+ * Best-effort sync for everyday commands: a no-op when logged out, a short
84
+ * timeout, and never throws. Offline is normal; the outbox is pushed next time.
85
+ * The only output is a one-line hint on stderr, and only in a terminal, so
86
+ * piped output (`devgrowth config show | jq`) stays clean.
87
+ */
88
+ export async function trySync() {
89
+ if (!(await auth.isLoggedIn())) return null;
90
+ try {
91
+ return await sync({ timeoutMs: 3000 });
92
+ } catch (err) {
93
+ if (process.stderr.isTTY) {
94
+ const hint = err.status === 401
95
+ ? 'Sync: your login expired or was revoked. Run `devgrowth login` to sync again.'
96
+ : 'Sync: offline, changes are saved locally and will sync later.';
97
+ process.stderr.write(`${chalk.gray(hint)}\n`);
98
+ }
99
+ return { error: err };
100
+ }
101
+ }