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.
- package/README.md +135 -111
- package/package.json +52 -48
- package/src/commands/config.js +54 -50
- package/src/commands/dashboard.js +13 -0
- package/src/commands/history.js +75 -73
- package/src/commands/log.js +40 -36
- package/src/commands/login.js +127 -0
- package/src/commands/logout.js +26 -0
- package/src/commands/milestone.js +54 -51
- package/src/commands/review.js +62 -57
- package/src/commands/start.js +97 -91
- package/src/commands/status.js +55 -53
- package/src/commands/sync.js +29 -0
- package/src/core/apiClient.js +46 -0
- package/src/core/auth.js +34 -0
- package/src/core/banner.js +293 -293
- package/src/core/configManager.js +57 -70
- package/src/core/eventLog.js +112 -0
- package/src/core/logger.js +62 -62
- package/src/core/milestones.js +62 -72
- package/src/core/prompt.js +58 -0
- package/src/core/rebuild.js +37 -0
- package/src/core/schedule.js +1 -70
- package/src/core/stats.js +43 -130
- package/src/core/storage.js +52 -33
- package/src/core/sync.js +101 -0
- package/src/index.js +221 -152
package/src/core/schedule.js
CHANGED
|
@@ -1,70 +1 @@
|
|
|
1
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|
package/src/core/storage.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
package/src/core/sync.js
ADDED
|
@@ -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
|
+
}
|