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,57 @@
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
- }
1
+ import * as storage from './storage.js';
2
+ import * as path from 'path';
3
+ import * as derive from 'devgrowth-core/derive';
4
+
5
+ export const DEFAULT_CONFIG = derive.DEFAULT_CONFIG;
6
+
7
+ function getConfigPath() {
8
+ return path.join(storage.getDevGrowthDir(), 'config.json');
9
+ }
10
+
11
+ /** Write config.json as-is. Sync notices the change and records it (see sync.js). */
12
+ export async function saveConfig(config) {
13
+ await storage.writeJson(getConfigPath(), config);
14
+ }
15
+
16
+ export async function createDefaultConfig() {
17
+ const configPath = path.join(storage.getDevGrowthDir(), 'config.json');
18
+ await storage.writeJson(configPath, DEFAULT_CONFIG);
19
+ return DEFAULT_CONFIG;
20
+ }
21
+
22
+ export async function getConfig() {
23
+ const configPath = path.join(storage.getDevGrowthDir(), 'config.json');
24
+ const exists = await storage.fileExists(configPath);
25
+ if (!exists) {
26
+ throw new Error('Config not found. Run `devgrowth init` first.');
27
+ }
28
+ return await storage.readJson(configPath);
29
+ }
30
+
31
+ export async function updateConfig(updates) {
32
+ const config = await getConfig();
33
+ const merged = { ...config, ...updates };
34
+ // Deep merge for nested objects (simple version)
35
+ for (const key of Object.keys(updates)) {
36
+ if (typeof updates[key] === 'object' && !Array.isArray(updates[key]) && updates[key] !== null) {
37
+ merged[key] = { ...config[key], ...updates[key] };
38
+ }
39
+ }
40
+ const configPath = path.join(storage.getDevGrowthDir(), 'config.json');
41
+ await storage.writeJson(configPath, merged);
42
+ }
43
+
44
+ export async function setConfigValue(keyPath, value) {
45
+ const config = JSON.parse(JSON.stringify(await getConfig()));
46
+ const keys = keyPath.split('.');
47
+ let target = config;
48
+ for (let i = 0; i < keys.length - 1; i++) {
49
+ if (typeof target[keys[i]] !== 'object' || target[keys[i]] === null) {
50
+ throw new Error(`Invalid key path: "${keyPath}" — "${keys[i]}" is not an object.`);
51
+ }
52
+ target = target[keys[i]];
53
+ }
54
+ target[keys[keys.length - 1]] = value;
55
+ const configPath = path.join(storage.getDevGrowthDir(), 'config.json');
56
+ await storage.writeJson(configPath, config);
57
+ }
@@ -0,0 +1,112 @@
1
+ import * as storage from './storage.js';
2
+ import * as path from 'path';
3
+ import { randomUUID } from 'crypto';
4
+ import * as coreEvents from 'devgrowth-core/events';
5
+ import * as derive from 'devgrowth-core/derive';
6
+
7
+ // The local copy of this user's sync events (see packages/core/SYNC_API.md).
8
+ //
9
+ // events.jsonl every event, recorded here or pulled from the server, one JSON object per line
10
+ // sync.json { cursor, outbox, lastSyncAt }: pull position and ids not yet pushed
11
+ //
12
+ // Events are recorded whether or not the user is logged in, so nothing done
13
+ // offline (or before `devgrowth login`) is lost.
14
+
15
+ function eventsPath() {
16
+ return path.join(storage.getDevGrowthDir(), 'events.jsonl');
17
+ }
18
+
19
+ function syncStatePath() {
20
+ return path.join(storage.getDevGrowthDir(), 'sync.json');
21
+ }
22
+
23
+ export async function getSyncState() {
24
+ const file = syncStatePath();
25
+ const state = (await storage.fileExists(file)) ? await storage.readJson(file) : {};
26
+ return { cursor: 0, outbox: [], lastSyncAt: null, ...state };
27
+ }
28
+
29
+ export async function saveSyncState(state) {
30
+ await storage.writeJson(syncStatePath(), state);
31
+ }
32
+
33
+ /** Validate, append and queue an event for the next push. Returns the event. */
34
+ export async function recordEvent(type, payload, occurredAt = new Date()) {
35
+ const event = coreEvents.assertValidEvent({
36
+ id: randomUUID(),
37
+ type,
38
+ v: coreEvents.SCHEMA_VERSION,
39
+ occurredAt: occurredAt.toISOString(),
40
+ payload
41
+ });
42
+ await storage.appendFile(eventsPath(), `${JSON.stringify(event)}\n`);
43
+ const state = await getSyncState();
44
+ state.outbox.push(event.id);
45
+ await saveSyncState(state);
46
+ return event;
47
+ }
48
+
49
+ /**
50
+ * Record a session. `date` is the session-adjusted date the command already
51
+ * uses for its log entry (`schedule.getSessionDate(new Date())`).
52
+ */
53
+ export async function recordSession(date, { skill, resource, duration, status, message }) {
54
+ return recordEvent('session', {
55
+ skill,
56
+ resource: resource ?? null,
57
+ duration,
58
+ status,
59
+ message,
60
+ ...derive.sessionKeysFor(date),
61
+ startedAt: date.toTimeString().slice(0, 5)
62
+ });
63
+ }
64
+
65
+ export async function recordReview(date) {
66
+ return recordEvent('review', derive.reviewKeysFor(date));
67
+ }
68
+
69
+ /**
70
+ * Every event, de-duplicated by id. A line that isn't valid JSON (for example a
71
+ * write cut off by a crash) is skipped rather than breaking every command.
72
+ */
73
+ export async function readAllEvents() {
74
+ const file = eventsPath();
75
+ if (!(await storage.fileExists(file))) return [];
76
+ const seen = new Set();
77
+ const events = [];
78
+ for (const line of (await storage.readFile(file)).split('\n')) {
79
+ if (!line.trim()) continue;
80
+ let event;
81
+ try {
82
+ event = JSON.parse(line);
83
+ } catch {
84
+ continue;
85
+ }
86
+ if (!event?.id || seen.has(event.id)) continue;
87
+ seen.add(event.id);
88
+ events.push(event);
89
+ }
90
+ return events;
91
+ }
92
+
93
+ /** Append events pulled from the server that aren't already here; returns just those. */
94
+ export async function mergeRemoteEvents(list) {
95
+ const known = new Set((await readAllEvents()).map(e => e.id));
96
+ const fresh = [];
97
+ for (const { id, type, v, occurredAt, payload, deviceId } of list) {
98
+ if (known.has(id)) continue;
99
+ known.add(id);
100
+ fresh.push({ id, type, v, occurredAt, payload, ...(deviceId ? { deviceId } : {}) });
101
+ }
102
+ if (fresh.length > 0) {
103
+ await storage.appendFile(eventsPath(), fresh.map(e => `${JSON.stringify(e)}\n`).join(''));
104
+ }
105
+ return fresh;
106
+ }
107
+
108
+ /** Forget the local event log and sync position (used when adopting an account's history). */
109
+ export async function resetLocalLog() {
110
+ await storage.removePath(eventsPath());
111
+ await storage.removePath(syncStatePath());
112
+ }
@@ -1,62 +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
- }
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, { quiet = false } = {}) {
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) && !quiet) {
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
+ }
@@ -1,72 +1,62 @@
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
- }
1
+ import * as storage from './storage.js';
2
+ import * as path from 'path';
3
+ import * as derive from 'devgrowth-core/derive';
4
+ import * as eventLog from './eventLog.js';
5
+
6
+ // Defaults live in devgrowth-core so milestone events validate identically everywhere.
7
+ export const DEFAULT_MILESTONES = derive.DEFAULT_MILESTONES;
8
+
9
+ function getMilestonesPath() {
10
+ return path.join(storage.getDevGrowthDir(), 'milestones.json');
11
+ }
12
+
13
+ export async function getMilestones() {
14
+ const milestonesPath = getMilestonesPath();
15
+ const exists = await storage.fileExists(milestonesPath);
16
+ if (!exists) {
17
+ return JSON.parse(JSON.stringify(DEFAULT_MILESTONES));
18
+ }
19
+ return await storage.readJson(milestonesPath);
20
+ }
21
+
22
+ export async function saveMilestones(data) {
23
+ await storage.writeJson(getMilestonesPath(), data);
24
+ }
25
+
26
+ export async function checkMilestone(skill, month, item) {
27
+ const milestones = await getMilestones();
28
+ // Own-property checks only: `in` and plain lookups also match inherited
29
+ // names like `toString`, which would write junk keys or crash.
30
+ if (!Object.hasOwn(milestones, skill)) {
31
+ throw new Error(`Unknown skill: ${skill}`);
32
+ }
33
+ const monthKey = month.toString().toLowerCase();
34
+ if (!Object.hasOwn(milestones[skill], monthKey)) {
35
+ throw new Error(`Invalid month: ${month}`);
36
+ }
37
+ const items = milestones[skill][monthKey].items;
38
+ if (!Object.hasOwn(items, item)) {
39
+ throw new Error(`Invalid item: ${item}`);
40
+ }
41
+ items[item] = true;
42
+ await saveMilestones(milestones);
43
+ await eventLog.recordEvent('milestone_check', { skill, month: monthKey, item });
44
+ return milestones[skill];
45
+ }
46
+
47
+ export async function getProgress(skill) {
48
+ const milestones = await getMilestones();
49
+ if (!Object.hasOwn(milestones, skill)) {
50
+ throw new Error(`Unknown skill: ${skill}`);
51
+ }
52
+ let total = 0;
53
+ let completed = 0;
54
+ for (const month of Object.values(milestones[skill])) {
55
+ for (const value of Object.values(month.items)) {
56
+ total += 1;
57
+ if (value) completed += 1;
58
+ }
59
+ }
60
+ const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
61
+ return { total, completed, percentage };
62
+ }
@@ -0,0 +1,58 @@
1
+ import * as readline from 'readline';
2
+
3
+ // Terminal prompts for login. When stdin is not a terminal (scripts, CI, tests)
4
+ // answers are read line by line from stdin instead, e.g.
5
+ // printf 'my password\n' | devgrowth login --email me@example.com
6
+
7
+ let pipedLines = null;
8
+
9
+ async function nextPipedLine() {
10
+ if (pipedLines === null) {
11
+ let input = '';
12
+ for await (const chunk of process.stdin) input += chunk;
13
+ pipedLines = input.split(/\r?\n/);
14
+ }
15
+ return pipedLines.length > 0 ? pipedLines.shift() : '';
16
+ }
17
+
18
+ export async function ask(question) {
19
+ if (!process.stdin.isTTY) return (await nextPipedLine()).trim();
20
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
21
+ try {
22
+ return (await new Promise(resolve => rl.question(question, resolve))).trim();
23
+ } finally {
24
+ rl.close();
25
+ }
26
+ }
27
+
28
+ /** Like `ask`, but typed characters are not echoed. */
29
+ export async function askHidden(question) {
30
+ if (!process.stdin.isTTY) return nextPipedLine();
31
+ return new Promise((resolve, reject) => {
32
+ const stdin = process.stdin;
33
+ let value = '';
34
+ process.stdout.write(question);
35
+ stdin.setRawMode(true);
36
+ stdin.setEncoding('utf8');
37
+ stdin.resume();
38
+
39
+ function finish(error) {
40
+ stdin.removeListener('data', onData);
41
+ stdin.setRawMode(false);
42
+ stdin.pause();
43
+ process.stdout.write('\n');
44
+ if (error) reject(error); else resolve(value);
45
+ }
46
+
47
+ function onData(chunk) {
48
+ for (const ch of chunk) {
49
+ if (ch === '\r' || ch === '\n') return finish();
50
+ if (ch === '\u0003') return finish(new Error('Cancelled.')); // Ctrl+C
51
+ if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
52
+ else if (ch >= ' ') value += ch;
53
+ }
54
+ }
55
+
56
+ stdin.on('data', onData);
57
+ });
58
+ }
@@ -0,0 +1,37 @@
1
+ import * as derive from 'devgrowth-core/derive';
2
+ import * as eventLog from './eventLog.js';
3
+ import * as stats from './stats.js';
4
+ import * as milestones from './milestones.js';
5
+ import * as configManager from './configManager.js';
6
+ import * as logger from './logger.js';
7
+
8
+ /** The local Date a session's log entry is written under: its session day at `startedAt`. */
9
+ function logDateFor(payload) {
10
+ const [y, m, d] = payload.sessionDate.split('-').map(Number);
11
+ const [hh, mm] = (payload.startedAt || '21:00').split(':').map(Number);
12
+ return new Date(y, m - 1, d, hh, mm);
13
+ }
14
+
15
+ /**
16
+ * Re-derive stats.json, milestones.json and config.json from the full event
17
+ * log, exactly as every other device and the dashboard do, and append log
18
+ * entries for sessions that arrived from other devices.
19
+ *
20
+ * Markdown logs are only ever appended to, never regenerated, so notes you
21
+ * typed into a week file by hand survive a sync.
22
+ */
23
+ export async function rebuild(newRemoteEvents = []) {
24
+ const state = derive.deriveState(await eventLog.readAllEvents());
25
+ await stats.saveStats(state.stats);
26
+ await milestones.saveMilestones(state.milestones);
27
+ // No config in any event (can't normally happen after a baseline): keep the local file.
28
+ if (state.config) await configManager.saveConfig(state.config);
29
+
30
+ const sessions = newRemoteEvents
31
+ .filter(e => e.type === 'session')
32
+ .sort((a, b) => Date.parse(a.occurredAt) - Date.parse(b.occurredAt));
33
+ for (const { payload: p } of sessions) {
34
+ await logger.writeLogEntry(logDateFor(p), p.skill, p.resource, p.duration, p.status, p.message, { quiet: true });
35
+ }
36
+ return state;
37
+ }