endurance-coach 0.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/LICENSE.md +21 -0
- package/README.md +94 -0
- package/bin/claude-coach.js +10 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +1077 -0
- package/dist/db/client.d.ts +8 -0
- package/dist/db/client.js +111 -0
- package/dist/db/migrate.d.ts +1 -0
- package/dist/db/migrate.js +14 -0
- package/dist/db/schema.sql +105 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +13 -0
- package/dist/lib/config.d.ts +27 -0
- package/dist/lib/config.js +86 -0
- package/dist/lib/logging.d.ts +13 -0
- package/dist/lib/logging.js +28 -0
- package/dist/schema/training-plan.d.ts +288 -0
- package/dist/schema/training-plan.js +88 -0
- package/dist/schema/training-plan.schema.d.ts +1875 -0
- package/dist/schema/training-plan.schema.js +418 -0
- package/dist/strava/api.d.ts +5 -0
- package/dist/strava/api.js +63 -0
- package/dist/strava/oauth.d.ts +4 -0
- package/dist/strava/oauth.js +113 -0
- package/dist/strava/types.d.ts +46 -0
- package/dist/strava/types.js +1 -0
- package/dist/viewer/lib/UpdatePlan.d.ts +48 -0
- package/dist/viewer/lib/UpdatePlan.js +209 -0
- package/dist/viewer/lib/export/erg.d.ts +26 -0
- package/dist/viewer/lib/export/erg.js +208 -0
- package/dist/viewer/lib/export/fit.d.ts +25 -0
- package/dist/viewer/lib/export/fit.js +308 -0
- package/dist/viewer/lib/export/ics.d.ts +13 -0
- package/dist/viewer/lib/export/ics.js +142 -0
- package/dist/viewer/lib/export/index.d.ts +50 -0
- package/dist/viewer/lib/export/index.js +229 -0
- package/dist/viewer/lib/export/zwo.d.ts +21 -0
- package/dist/viewer/lib/export/zwo.js +233 -0
- package/dist/viewer/lib/utils.d.ts +14 -0
- package/dist/viewer/lib/utils.js +123 -0
- package/dist/viewer/main.d.ts +5 -0
- package/dist/viewer/main.js +6 -0
- package/dist/viewer/stores/changes.d.ts +21 -0
- package/dist/viewer/stores/changes.js +49 -0
- package/dist/viewer/stores/plan.d.ts +11 -0
- package/dist/viewer/stores/plan.js +40 -0
- package/dist/viewer/stores/settings.d.ts +53 -0
- package/dist/viewer/stores/settings.js +215 -0
- package/package.json +74 -0
- package/templates/plan-viewer.html +70 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, createConfig, } from "./lib/config.js";
|
|
2
|
+
import { log } from "./lib/logging.js";
|
|
3
|
+
import { migrate } from "./db/migrate.js";
|
|
4
|
+
import { execute, initDatabase, query, queryJson } from "./db/client.js";
|
|
5
|
+
import { getValidTokens } from "./strava/oauth.js";
|
|
6
|
+
import { getAllActivities, getAthlete } from "./strava/api.js";
|
|
7
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
|
11
|
+
import { validatePlan, formatValidationErrors } from "./schema/training-plan.schema.js";
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Proxy Configuration
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Configure proxy for fetch() if HTTP_PROXY or HTTPS_PROXY is set
|
|
17
|
+
const proxyUrl = process.env.HTTPS_PROXY ||
|
|
18
|
+
process.env.https_proxy ||
|
|
19
|
+
process.env.HTTP_PROXY ||
|
|
20
|
+
process.env.http_proxy;
|
|
21
|
+
if (proxyUrl) {
|
|
22
|
+
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
|
23
|
+
}
|
|
24
|
+
function parseArgs() {
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
if (args.length === 0 || args[0] === "sync") {
|
|
27
|
+
// Sync command (default)
|
|
28
|
+
const syncArgs = { command: "sync" };
|
|
29
|
+
for (const arg of args) {
|
|
30
|
+
if (arg.startsWith("--client-id=")) {
|
|
31
|
+
syncArgs.clientId = arg.split("=")[1];
|
|
32
|
+
}
|
|
33
|
+
else if (arg.startsWith("--client-secret=")) {
|
|
34
|
+
syncArgs.clientSecret = arg.split("=")[1];
|
|
35
|
+
}
|
|
36
|
+
else if (arg.startsWith("--access-token=")) {
|
|
37
|
+
syncArgs.accessToken = arg.split("=")[1];
|
|
38
|
+
}
|
|
39
|
+
else if (arg.startsWith("--refresh-token=")) {
|
|
40
|
+
syncArgs.refreshToken = arg.split("=")[1];
|
|
41
|
+
}
|
|
42
|
+
else if (arg.startsWith("--days=")) {
|
|
43
|
+
syncArgs.days = parseInt(arg.split("=")[1]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return syncArgs;
|
|
47
|
+
}
|
|
48
|
+
if (args[0] === "render") {
|
|
49
|
+
if (!args[1]) {
|
|
50
|
+
log.error("render command requires an input file");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const renderArgs = {
|
|
54
|
+
command: "render",
|
|
55
|
+
inputFile: args[1],
|
|
56
|
+
};
|
|
57
|
+
for (let i = 2; i < args.length; i++) {
|
|
58
|
+
if (args[i] === "--output" || args[i] === "-o") {
|
|
59
|
+
renderArgs.outputFile = args[i + 1];
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
else if (args[i].startsWith("--output=")) {
|
|
63
|
+
renderArgs.outputFile = args[i].split("=")[1];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return renderArgs;
|
|
67
|
+
}
|
|
68
|
+
if (args[0] === "query") {
|
|
69
|
+
if (!args[1]) {
|
|
70
|
+
log.error("query command requires a SQL statement");
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const queryArgs = {
|
|
74
|
+
command: "query",
|
|
75
|
+
sql: args[1],
|
|
76
|
+
json: args.includes("--json"),
|
|
77
|
+
};
|
|
78
|
+
return queryArgs;
|
|
79
|
+
}
|
|
80
|
+
if (args[0] === "auth") {
|
|
81
|
+
const authArgs = { command: "auth" };
|
|
82
|
+
for (const arg of args) {
|
|
83
|
+
if (arg.startsWith("--client-id=")) {
|
|
84
|
+
authArgs.clientId = arg.slice("--client-id=".length);
|
|
85
|
+
}
|
|
86
|
+
else if (arg.startsWith("--client-secret=")) {
|
|
87
|
+
authArgs.clientSecret = arg.slice("--client-secret=".length);
|
|
88
|
+
}
|
|
89
|
+
else if (arg.startsWith("--code=")) {
|
|
90
|
+
authArgs.code = arg.slice("--code=".length);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return authArgs;
|
|
94
|
+
}
|
|
95
|
+
if (args[0] === "validate") {
|
|
96
|
+
if (!args[1]) {
|
|
97
|
+
log.error("validate command requires an input file");
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
command: "validate",
|
|
102
|
+
inputFile: args[1],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (args[0] === "schema") {
|
|
106
|
+
return { command: "schema" };
|
|
107
|
+
}
|
|
108
|
+
if (args[0] === "modify") {
|
|
109
|
+
if (!args[1] || !args[2]) {
|
|
110
|
+
log.error("modify command requires --backup and --plan arguments");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const modifyArgs = {
|
|
114
|
+
command: "modify",
|
|
115
|
+
backup: "",
|
|
116
|
+
plan: "",
|
|
117
|
+
};
|
|
118
|
+
for (let i = 1; i < args.length; i++) {
|
|
119
|
+
if (args[i] === "--backup" || args[i] === "-b") {
|
|
120
|
+
modifyArgs.backup = args[i + 1];
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
else if (args[i].startsWith("--backup=")) {
|
|
124
|
+
modifyArgs.backup = args[i].split("=")[1];
|
|
125
|
+
}
|
|
126
|
+
else if (args[i] === "--plan" || args[i] === "-p") {
|
|
127
|
+
modifyArgs.plan = args[i + 1];
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
else if (args[i].startsWith("--plan=")) {
|
|
131
|
+
modifyArgs.plan = args[i].split("=")[1];
|
|
132
|
+
}
|
|
133
|
+
else if (args[i] === "--output" || args[i] === "-o") {
|
|
134
|
+
modifyArgs.output = args[i + 1];
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
else if (args[i].startsWith("--output=")) {
|
|
138
|
+
modifyArgs.output = args[i].split("=")[1];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!modifyArgs.backup || !modifyArgs.plan) {
|
|
142
|
+
log.error("Both --backup and --plan are required");
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
return modifyArgs;
|
|
146
|
+
}
|
|
147
|
+
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
148
|
+
return { command: "help" };
|
|
149
|
+
}
|
|
150
|
+
log.error(`Unknown command: ${args[0]}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
function printHelp() {
|
|
154
|
+
console.log(`
|
|
155
|
+
Endurance Coach - Training Plan Tools
|
|
156
|
+
|
|
157
|
+
Usage: npx endurance-coach <command> [options]
|
|
158
|
+
|
|
159
|
+
Commands:
|
|
160
|
+
sync Sync activities from Strava
|
|
161
|
+
auth Get Strava authorization URL or exchange code for tokens
|
|
162
|
+
schema Print the training plan JSON schema reference
|
|
163
|
+
validate <file> Validate a training plan JSON against the schema
|
|
164
|
+
render <file> Render a training plan JSON to HTML
|
|
165
|
+
query <sql> Run a SQL query against the database
|
|
166
|
+
modify Apply backup changes to a training plan
|
|
167
|
+
help Show this help message
|
|
168
|
+
|
|
169
|
+
Auth Options (for headless/Claude environments):
|
|
170
|
+
--client-id=ID Strava API client ID
|
|
171
|
+
--client-secret=SEC Strava API client secret
|
|
172
|
+
--code=URL_OR_CODE Full redirect URL or just the authorization code
|
|
173
|
+
|
|
174
|
+
Step 1: Run 'auth' with credentials to get authorization URL
|
|
175
|
+
Step 2: User clicks URL, authorizes, copies entire redirect URL
|
|
176
|
+
Step 3: Run 'auth --code=URL' to exchange for tokens
|
|
177
|
+
Step 4: Run 'sync' to fetch activities
|
|
178
|
+
|
|
179
|
+
Sync Options:
|
|
180
|
+
--client-id=ID Strava API client ID (for OAuth flow)
|
|
181
|
+
--client-secret=SEC Strava API client secret (for OAuth flow)
|
|
182
|
+
--days=N Days of history to sync (default: 730)
|
|
183
|
+
|
|
184
|
+
Render Options:
|
|
185
|
+
--output, -o FILE Output HTML file (default: <input>.html)
|
|
186
|
+
|
|
187
|
+
Query Options:
|
|
188
|
+
--json Output as JSON (default: plain text)
|
|
189
|
+
|
|
190
|
+
Modify Options:
|
|
191
|
+
--backup, -b FILE Backup JSON file (exported from Settings)
|
|
192
|
+
--plan, -p FILE Training plan JSON file to modify
|
|
193
|
+
--output, -o FILE Output file (default: overwrites plan file)
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
# Headless auth flow (for Claude/automated environments)
|
|
197
|
+
npx endurance-coach auth --client-id=12345 --client-secret=abc123
|
|
198
|
+
# User clicks URL, copies code from failed redirect
|
|
199
|
+
npx endurance-coach auth --code=AUTHORIZATION_CODE
|
|
200
|
+
npx endurance-coach sync
|
|
201
|
+
|
|
202
|
+
# Interactive auth flow (opens browser)
|
|
203
|
+
npx endurance-coach sync --client-id=12345 --client-secret=abc123
|
|
204
|
+
|
|
205
|
+
# Get the schema reference for plan JSON
|
|
206
|
+
npx endurance-coach schema
|
|
207
|
+
|
|
208
|
+
# Validate a training plan JSON
|
|
209
|
+
npx endurance-coach validate plan.json
|
|
210
|
+
|
|
211
|
+
# Render a training plan to HTML (includes validation)
|
|
212
|
+
npx endurance-coach render plan.json --output my-plan.html
|
|
213
|
+
|
|
214
|
+
# Query the database
|
|
215
|
+
npx endurance-coach query "SELECT * FROM weekly_volume LIMIT 5"
|
|
216
|
+
|
|
217
|
+
# Apply backup changes to a training plan
|
|
218
|
+
npx endurance-coach modify --backup backup.json --plan plan.json
|
|
219
|
+
|
|
220
|
+
# Save modified plan to a new file
|
|
221
|
+
npx endurance-coach modify -b backup.json -p plan.json -o modified_plan.json
|
|
222
|
+
`);
|
|
223
|
+
}
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// Auth Command (for headless/Claude environments)
|
|
226
|
+
// ============================================================================
|
|
227
|
+
const REDIRECT_PORT = 8765;
|
|
228
|
+
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
|
|
229
|
+
const AUTHORIZE_URL = "https://www.strava.com/oauth/authorize";
|
|
230
|
+
const TOKEN_URL = "https://www.strava.com/oauth/token";
|
|
231
|
+
async function runAuth(args) {
|
|
232
|
+
// If code is provided, exchange it for tokens
|
|
233
|
+
if (args.code) {
|
|
234
|
+
if (!configExists()) {
|
|
235
|
+
log.error("No configuration found. Run 'auth' with --client-id and --client-secret first.");
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
// Extract code from full URL if user pasted the entire redirect URL
|
|
239
|
+
let code = args.code;
|
|
240
|
+
if (code.includes("localhost") || code.startsWith("http")) {
|
|
241
|
+
try {
|
|
242
|
+
const url = new URL(code);
|
|
243
|
+
const extractedCode = url.searchParams.get("code");
|
|
244
|
+
if (extractedCode) {
|
|
245
|
+
code = extractedCode;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
log.error("Could not find 'code' parameter in URL");
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Not a valid URL, use as-is
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const config = loadConfig();
|
|
257
|
+
log.start("Exchanging authorization code for tokens...");
|
|
258
|
+
const tokenResponse = await fetch(TOKEN_URL, {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "Content-Type": "application/json" },
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
client_id: config.strava.client_id,
|
|
263
|
+
client_secret: config.strava.client_secret,
|
|
264
|
+
code: code,
|
|
265
|
+
grant_type: "authorization_code",
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
if (!tokenResponse.ok) {
|
|
269
|
+
const error = await tokenResponse.text();
|
|
270
|
+
log.error(`Token exchange failed: ${error}`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
const data = await tokenResponse.json();
|
|
274
|
+
const tokens = {
|
|
275
|
+
access_token: data.access_token,
|
|
276
|
+
refresh_token: data.refresh_token,
|
|
277
|
+
expires_at: data.expires_at,
|
|
278
|
+
athlete_id: data.athlete.id,
|
|
279
|
+
};
|
|
280
|
+
saveTokens(tokens);
|
|
281
|
+
log.success(`Authenticated as ${data.athlete.firstname} ${data.athlete.lastname}`);
|
|
282
|
+
log.ready("Now run: npx endurance-coach sync");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Otherwise, generate and print the authorization URL
|
|
286
|
+
if (!args.clientId || !args.clientSecret) {
|
|
287
|
+
log.error("Required: --client-id and --client-secret");
|
|
288
|
+
log.info("Get these from: https://www.strava.com/settings/api");
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
// Save config for later use
|
|
292
|
+
const config = createConfig(args.clientId, args.clientSecret, 730);
|
|
293
|
+
saveConfig(config);
|
|
294
|
+
const authUrl = new URL(AUTHORIZE_URL);
|
|
295
|
+
authUrl.searchParams.set("client_id", args.clientId);
|
|
296
|
+
authUrl.searchParams.set("response_type", "code");
|
|
297
|
+
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
298
|
+
authUrl.searchParams.set("scope", "activity:read_all");
|
|
299
|
+
authUrl.searchParams.set("approval_prompt", "auto");
|
|
300
|
+
console.log("\nš AUTHORIZATION URL:\n");
|
|
301
|
+
console.log(authUrl.toString());
|
|
302
|
+
console.log("\nš INSTRUCTIONS:");
|
|
303
|
+
console.log("1. Open the URL above in a browser");
|
|
304
|
+
console.log("2. Click 'Authorize' on Strava");
|
|
305
|
+
console.log("3. You'll be redirected to a page that won't load (that's OK!)");
|
|
306
|
+
console.log("4. Copy the ENTIRE URL from your browser's address bar");
|
|
307
|
+
console.log("5. Paste it back to Claude\n");
|
|
308
|
+
}
|
|
309
|
+
// ============================================================================
|
|
310
|
+
// Sync Command
|
|
311
|
+
// ============================================================================
|
|
312
|
+
function escapeString(str) {
|
|
313
|
+
if (str == null)
|
|
314
|
+
return "NULL";
|
|
315
|
+
return `'${str.replace(/'/g, "''")}'`;
|
|
316
|
+
}
|
|
317
|
+
function insertActivity(activity) {
|
|
318
|
+
const sql = `
|
|
319
|
+
INSERT OR REPLACE INTO activities (
|
|
320
|
+
id, name, sport_type, start_date, elapsed_time, moving_time,
|
|
321
|
+
distance, total_elevation_gain, average_speed, max_speed,
|
|
322
|
+
average_heartrate, max_heartrate, average_watts, max_watts,
|
|
323
|
+
weighted_average_watts, kilojoules, suffer_score, average_cadence,
|
|
324
|
+
calories, description, workout_type, gear_id, raw_json, synced_at
|
|
325
|
+
) VALUES (
|
|
326
|
+
${activity.id},
|
|
327
|
+
${escapeString(activity.name)},
|
|
328
|
+
${escapeString(activity.sport_type)},
|
|
329
|
+
${escapeString(activity.start_date)},
|
|
330
|
+
${activity.elapsed_time ?? "NULL"},
|
|
331
|
+
${activity.moving_time ?? "NULL"},
|
|
332
|
+
${activity.distance ?? "NULL"},
|
|
333
|
+
${activity.total_elevation_gain ?? "NULL"},
|
|
334
|
+
${activity.average_speed ?? "NULL"},
|
|
335
|
+
${activity.max_speed ?? "NULL"},
|
|
336
|
+
${activity.average_heartrate ?? "NULL"},
|
|
337
|
+
${activity.max_heartrate ?? "NULL"},
|
|
338
|
+
${activity.average_watts ?? "NULL"},
|
|
339
|
+
${activity.max_watts ?? "NULL"},
|
|
340
|
+
${activity.weighted_average_watts ?? "NULL"},
|
|
341
|
+
${activity.kilojoules ?? "NULL"},
|
|
342
|
+
${activity.suffer_score ?? "NULL"},
|
|
343
|
+
${activity.average_cadence ?? "NULL"},
|
|
344
|
+
${activity.calories ?? "NULL"},
|
|
345
|
+
${escapeString(activity.description)},
|
|
346
|
+
${activity.workout_type ?? "NULL"},
|
|
347
|
+
${escapeString(activity.gear_id)},
|
|
348
|
+
${escapeString(JSON.stringify(activity))},
|
|
349
|
+
datetime('now')
|
|
350
|
+
);
|
|
351
|
+
`;
|
|
352
|
+
execute(sql);
|
|
353
|
+
}
|
|
354
|
+
function insertAthlete(athlete) {
|
|
355
|
+
const sql = `
|
|
356
|
+
INSERT OR REPLACE INTO athlete (id, firstname, lastname, weight, ftp, raw_json, updated_at)
|
|
357
|
+
VALUES (
|
|
358
|
+
${athlete.id},
|
|
359
|
+
${escapeString(athlete.firstname)},
|
|
360
|
+
${escapeString(athlete.lastname)},
|
|
361
|
+
${athlete.weight ?? "NULL"},
|
|
362
|
+
${athlete.ftp ?? "NULL"},
|
|
363
|
+
${escapeString(JSON.stringify(athlete))},
|
|
364
|
+
datetime('now')
|
|
365
|
+
);
|
|
366
|
+
`;
|
|
367
|
+
execute(sql);
|
|
368
|
+
}
|
|
369
|
+
async function runSync(args) {
|
|
370
|
+
log.box("Endurance Coach - Strava Sync");
|
|
371
|
+
// Step 0: Initialize SQLite backend
|
|
372
|
+
await initDatabase();
|
|
373
|
+
const syncDays = args.days || 730;
|
|
374
|
+
// Step 1: Handle token-based auth (no browser needed)
|
|
375
|
+
if (args.accessToken && args.refreshToken) {
|
|
376
|
+
log.info("Using provided access tokens...");
|
|
377
|
+
// Save tokens - we'll get athlete_id after fetching profile
|
|
378
|
+
// Set expiry to 1 hour from now (we have refresh token for renewal)
|
|
379
|
+
const tempTokens = {
|
|
380
|
+
access_token: args.accessToken,
|
|
381
|
+
refresh_token: args.refreshToken,
|
|
382
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
383
|
+
athlete_id: 0, // Will be updated after fetching athlete
|
|
384
|
+
};
|
|
385
|
+
saveTokens(tempTokens);
|
|
386
|
+
// Create minimal config if needed
|
|
387
|
+
if (!configExists()) {
|
|
388
|
+
// Token-based auth doesn't need client credentials for initial sync
|
|
389
|
+
// but we need them for token refresh - use placeholders
|
|
390
|
+
const config = createConfig("token-auth", "token-auth", syncDays);
|
|
391
|
+
saveConfig(config);
|
|
392
|
+
}
|
|
393
|
+
// Initialize database
|
|
394
|
+
migrate();
|
|
395
|
+
// Fetch athlete to get ID and validate tokens
|
|
396
|
+
log.start("Validating tokens and fetching athlete profile...");
|
|
397
|
+
const athlete = await getAthlete(tempTokens);
|
|
398
|
+
// Update tokens with real athlete ID
|
|
399
|
+
const tokens = { ...tempTokens, athlete_id: athlete.id };
|
|
400
|
+
saveTokens(tokens);
|
|
401
|
+
insertAthlete(athlete);
|
|
402
|
+
log.success(`Authenticated as ${athlete.firstname} ${athlete.lastname}`);
|
|
403
|
+
// Fetch activities
|
|
404
|
+
const afterDate = new Date();
|
|
405
|
+
afterDate.setDate(afterDate.getDate() - syncDays);
|
|
406
|
+
const activities = await getAllActivities(tokens, afterDate);
|
|
407
|
+
// Store activities
|
|
408
|
+
log.start("Storing activities in database...");
|
|
409
|
+
let count = 0;
|
|
410
|
+
for (const activity of activities) {
|
|
411
|
+
insertActivity(activity);
|
|
412
|
+
count++;
|
|
413
|
+
if (count % 50 === 0) {
|
|
414
|
+
log.progress(` Stored ${count}/${activities.length}...`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
log.progressEnd();
|
|
418
|
+
log.success(`Stored ${activities.length} activities`);
|
|
419
|
+
execute(`
|
|
420
|
+
INSERT INTO sync_log (started_at, completed_at, activities_synced, status)
|
|
421
|
+
VALUES (datetime('now'), datetime('now'), ${activities.length}, 'success');
|
|
422
|
+
`);
|
|
423
|
+
log.info(`Database: ${getDbPath()}`);
|
|
424
|
+
log.ready("Sync complete! You can now create training plans.");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Step 2: OAuth-based auth (requires browser)
|
|
428
|
+
if (!configExists()) {
|
|
429
|
+
if (args.clientId && args.clientSecret) {
|
|
430
|
+
log.info("Creating configuration from command line arguments...");
|
|
431
|
+
const config = createConfig(args.clientId, args.clientSecret, syncDays);
|
|
432
|
+
saveConfig(config);
|
|
433
|
+
log.success("Configuration saved");
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
log.info("No configuration found. Let's set things up.");
|
|
437
|
+
const config = await promptForConfig();
|
|
438
|
+
saveConfig(config);
|
|
439
|
+
log.success("Configuration saved");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const config = loadConfig();
|
|
443
|
+
const configSyncDays = args.days || config.sync_days || 730;
|
|
444
|
+
// Initialize database
|
|
445
|
+
migrate();
|
|
446
|
+
// Authenticate with Strava (opens browser)
|
|
447
|
+
const tokens = await getValidTokens();
|
|
448
|
+
// Step 4: Fetch and store athlete profile
|
|
449
|
+
log.start("Fetching athlete profile...");
|
|
450
|
+
const athlete = await getAthlete(tokens);
|
|
451
|
+
insertAthlete(athlete);
|
|
452
|
+
log.success(`Athlete: ${athlete.firstname} ${athlete.lastname}`);
|
|
453
|
+
// Step 5: Fetch activities
|
|
454
|
+
const afterDate = new Date();
|
|
455
|
+
afterDate.setDate(afterDate.getDate() - configSyncDays);
|
|
456
|
+
const activities = await getAllActivities(tokens, afterDate);
|
|
457
|
+
// Step 6: Store activities
|
|
458
|
+
log.start("Storing activities in database...");
|
|
459
|
+
let count = 0;
|
|
460
|
+
for (const activity of activities) {
|
|
461
|
+
insertActivity(activity);
|
|
462
|
+
count++;
|
|
463
|
+
if (count % 50 === 0) {
|
|
464
|
+
log.progress(` Stored ${count}/${activities.length}...`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
log.progressEnd();
|
|
468
|
+
log.success(`Stored ${activities.length} activities`);
|
|
469
|
+
// Step 7: Log sync
|
|
470
|
+
execute(`
|
|
471
|
+
INSERT INTO sync_log (started_at, completed_at, activities_synced, status)
|
|
472
|
+
VALUES (datetime('now'), datetime('now'), ${activities.length}, 'success');
|
|
473
|
+
`);
|
|
474
|
+
log.info(`Database: ${getDbPath()}`);
|
|
475
|
+
log.ready(`Query with: sqlite3 -json "${getDbPath()}" "SELECT * FROM weekly_volume"`);
|
|
476
|
+
}
|
|
477
|
+
// ============================================================================
|
|
478
|
+
// Render Command
|
|
479
|
+
// ============================================================================
|
|
480
|
+
function getTemplatePath() {
|
|
481
|
+
// Look for template in multiple locations
|
|
482
|
+
const locations = [
|
|
483
|
+
join(__dirname, "..", "templates", "plan-viewer.html"),
|
|
484
|
+
join(__dirname, "..", "..", "templates", "plan-viewer.html"),
|
|
485
|
+
join(process.cwd(), "templates", "plan-viewer.html"),
|
|
486
|
+
];
|
|
487
|
+
for (const loc of locations) {
|
|
488
|
+
try {
|
|
489
|
+
readFileSync(loc);
|
|
490
|
+
return loc;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// Continue to next location
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
throw new Error("Could not find plan-viewer.html template");
|
|
497
|
+
}
|
|
498
|
+
function runRender(args) {
|
|
499
|
+
log.start("Rendering training plan...");
|
|
500
|
+
// Read the plan JSON
|
|
501
|
+
let planJson;
|
|
502
|
+
try {
|
|
503
|
+
planJson = readFileSync(args.inputFile, "utf-8");
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
// Parse JSON
|
|
510
|
+
let planData;
|
|
511
|
+
try {
|
|
512
|
+
planData = JSON.parse(planJson);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
log.error("Input file is not valid JSON");
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
// Validate against schema
|
|
519
|
+
const validation = validatePlan(planData);
|
|
520
|
+
if (!validation.success) {
|
|
521
|
+
log.error("Training plan validation failed:");
|
|
522
|
+
console.error(formatValidationErrors(validation.errors));
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
log.success("Plan schema validated successfully");
|
|
526
|
+
// Read the template
|
|
527
|
+
const templatePath = getTemplatePath();
|
|
528
|
+
let template = readFileSync(templatePath, "utf-8");
|
|
529
|
+
// Replace the plan data in the template
|
|
530
|
+
const planDataRegex = /<script type="application\/json" id="plan-data">[\s\S]*?<\/script>/;
|
|
531
|
+
const newPlanData = `<script type="application/json" id="plan-data">\n${planJson}\n</script>`;
|
|
532
|
+
template = template.replace(planDataRegex, newPlanData);
|
|
533
|
+
// Output
|
|
534
|
+
if (args.outputFile) {
|
|
535
|
+
writeFileSync(args.outputFile, template);
|
|
536
|
+
log.success(`Training plan rendered to: ${args.outputFile}`);
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
// Output to stdout
|
|
540
|
+
console.log(template);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// ============================================================================
|
|
544
|
+
// Query Command
|
|
545
|
+
// ============================================================================
|
|
546
|
+
async function runQuery(args) {
|
|
547
|
+
await initDatabase();
|
|
548
|
+
if (args.json) {
|
|
549
|
+
const results = queryJson(args.sql);
|
|
550
|
+
console.log(JSON.stringify(results, null, 2));
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const result = query(args.sql);
|
|
554
|
+
console.log(result);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// ============================================================================
|
|
558
|
+
// Validate Command
|
|
559
|
+
// ============================================================================
|
|
560
|
+
function runValidate(args) {
|
|
561
|
+
log.start("Validating training plan...");
|
|
562
|
+
// Read the plan JSON
|
|
563
|
+
let planJson;
|
|
564
|
+
try {
|
|
565
|
+
planJson = readFileSync(args.inputFile, "utf-8");
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
569
|
+
process.exit(1);
|
|
570
|
+
}
|
|
571
|
+
// Parse JSON
|
|
572
|
+
let planData;
|
|
573
|
+
try {
|
|
574
|
+
planData = JSON.parse(planJson);
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
log.error("Input file is not valid JSON");
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
// Validate against schema
|
|
581
|
+
const validation = validatePlan(planData);
|
|
582
|
+
if (!validation.success) {
|
|
583
|
+
log.error("Validation failed:");
|
|
584
|
+
console.error(formatValidationErrors(validation.errors));
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
log.success("Plan is valid!");
|
|
588
|
+
}
|
|
589
|
+
// ============================================================================
|
|
590
|
+
// Schema Command
|
|
591
|
+
// ============================================================================
|
|
592
|
+
function runSchema() {
|
|
593
|
+
console.log(`
|
|
594
|
+
# Training Plan JSON Schema Reference
|
|
595
|
+
|
|
596
|
+
This document describes the required structure for training plan JSON files.
|
|
597
|
+
|
|
598
|
+
## Root Structure
|
|
599
|
+
|
|
600
|
+
\`\`\`typescript
|
|
601
|
+
{
|
|
602
|
+
version: "1.0", // Required: Must be exactly "1.0"
|
|
603
|
+
meta: PlanMeta, // Required: Plan metadata
|
|
604
|
+
preferences: UnitPreferences, // Required: Unit system preferences
|
|
605
|
+
assessment: AthleteAssessment, // Required: Athlete fitness assessment
|
|
606
|
+
zones: AthleteZones, // Required: Training zones
|
|
607
|
+
phases: TrainingPhase[], // Required: Macro training phases
|
|
608
|
+
weeks: TrainingWeek[], // Required: Weekly training schedule
|
|
609
|
+
raceStrategy: RaceStrategy // Required: Race day strategy
|
|
610
|
+
}
|
|
611
|
+
\`\`\`
|
|
612
|
+
|
|
613
|
+
## Enums (Valid Values)
|
|
614
|
+
|
|
615
|
+
### Sport
|
|
616
|
+
\`"swim" | "bike" | "run" | "strength" | "brick" | "race" | "rest"\`
|
|
617
|
+
|
|
618
|
+
### WorkoutType
|
|
619
|
+
\`"rest" | "recovery" | "endurance" | "tempo" | "threshold" | "intervals" | "vo2max" | "sprint" | "race" | "brick" | "technique" | "openwater" | "hills" | "long"\`
|
|
620
|
+
|
|
621
|
+
### FoundationLevel
|
|
622
|
+
\`"beginner" | "intermediate" | "advanced" | "elite"\`
|
|
623
|
+
|
|
624
|
+
### Unit Preferences
|
|
625
|
+
- swim: \`"meters" | "yards"\`
|
|
626
|
+
- bike: \`"kilometers" | "miles"\`
|
|
627
|
+
- run: \`"kilometers" | "miles"\`
|
|
628
|
+
- firstDayOfWeek: \`"monday" | "sunday"\`
|
|
629
|
+
|
|
630
|
+
## Key Objects
|
|
631
|
+
|
|
632
|
+
### PlanMeta
|
|
633
|
+
\`\`\`typescript
|
|
634
|
+
{
|
|
635
|
+
id: string, // Unique plan identifier
|
|
636
|
+
athlete: string, // Athlete's name
|
|
637
|
+
event: string, // Target event name
|
|
638
|
+
eventDate: "YYYY-MM-DD", // Event date (ISO format)
|
|
639
|
+
planStartDate: "YYYY-MM-DD", // Plan start date
|
|
640
|
+
planEndDate: "YYYY-MM-DD", // Plan end date
|
|
641
|
+
createdAt: string, // ISO datetime
|
|
642
|
+
updatedAt: string, // ISO datetime
|
|
643
|
+
totalWeeks: number, // Total weeks in plan
|
|
644
|
+
generatedBy: string // "Endurance Coach"
|
|
645
|
+
}
|
|
646
|
+
\`\`\`
|
|
647
|
+
|
|
648
|
+
### Workout
|
|
649
|
+
\`\`\`typescript
|
|
650
|
+
{
|
|
651
|
+
id: string, // Required: Unique workout ID
|
|
652
|
+
sport: Sport, // Required: See Sport enum
|
|
653
|
+
type: WorkoutType, // Required: See WorkoutType enum
|
|
654
|
+
name: string, // Required: Workout name
|
|
655
|
+
description: string, // Required: Workout description
|
|
656
|
+
durationMinutes?: number, // Optional: Duration in minutes
|
|
657
|
+
distanceMeters?: number, // Optional: Distance in meters
|
|
658
|
+
primaryZone?: string, // Optional: Target zone ("Zone 2", etc.)
|
|
659
|
+
targetHR?: { low: number, high: number },
|
|
660
|
+
targetPower?: { low: number, high: number },
|
|
661
|
+
targetPace?: { low: string, high: string },
|
|
662
|
+
rpe?: number, // Optional: 1-10 RPE scale
|
|
663
|
+
structure?: StructuredWorkout, // Optional: For device export
|
|
664
|
+
humanReadable?: string, // Optional: Workout text
|
|
665
|
+
completed: boolean // Required: Always false for new plans
|
|
666
|
+
}
|
|
667
|
+
\`\`\`
|
|
668
|
+
|
|
669
|
+
### TrainingDay
|
|
670
|
+
\`\`\`typescript
|
|
671
|
+
{
|
|
672
|
+
date: "YYYY-MM-DD", // Required: ISO date format
|
|
673
|
+
dayOfWeek: string, // Required: "Monday", "Tuesday", etc.
|
|
674
|
+
workouts: Workout[] // Required: Array of workouts
|
|
675
|
+
}
|
|
676
|
+
\`\`\`
|
|
677
|
+
|
|
678
|
+
### TrainingWeek
|
|
679
|
+
\`\`\`typescript
|
|
680
|
+
{
|
|
681
|
+
weekNumber: number, // Required: 1-based week number
|
|
682
|
+
startDate: "YYYY-MM-DD", // Required: Week start date
|
|
683
|
+
endDate: "YYYY-MM-DD", // Required: Week end date
|
|
684
|
+
phase: string, // Required: Phase name
|
|
685
|
+
focus: string, // Required: Week focus
|
|
686
|
+
targetHours: number, // Required: Target hours
|
|
687
|
+
days: TrainingDay[], // Required: 7 days
|
|
688
|
+
summary: WeekSummary, // Required: Week totals
|
|
689
|
+
isRecoveryWeek: boolean // Required: Recovery week flag
|
|
690
|
+
}
|
|
691
|
+
\`\`\`
|
|
692
|
+
|
|
693
|
+
### WeekSummary
|
|
694
|
+
\`\`\`typescript
|
|
695
|
+
{
|
|
696
|
+
totalHours: number, // Required: Total hours
|
|
697
|
+
totalTSS?: number, // Optional: Training stress score
|
|
698
|
+
bySport?: { // Optional: Breakdown by sport
|
|
699
|
+
[sport]: { sessions: number, hours: number, km?: number }
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
\`\`\`
|
|
703
|
+
|
|
704
|
+
### AthleteAssessment
|
|
705
|
+
\`\`\`typescript
|
|
706
|
+
{
|
|
707
|
+
foundation: {
|
|
708
|
+
raceHistory: string[], // Past race names
|
|
709
|
+
peakTrainingLoad: number, // Peak hours/week
|
|
710
|
+
foundationLevel: FoundationLevel,
|
|
711
|
+
yearsInSport: number
|
|
712
|
+
},
|
|
713
|
+
currentForm: {
|
|
714
|
+
weeklyVolume: { total: number, swim?: number, bike?: number, run?: number },
|
|
715
|
+
longestSessions: { swim?: number, bike?: number, run?: number },
|
|
716
|
+
consistency: number // Sessions/week
|
|
717
|
+
},
|
|
718
|
+
strengths: [{ sport: Sport, evidence: string }],
|
|
719
|
+
limiters: [{ sport: Sport, evidence: string }],
|
|
720
|
+
constraints: string[] // Schedule/injury constraints
|
|
721
|
+
}
|
|
722
|
+
\`\`\`
|
|
723
|
+
|
|
724
|
+
### TrainingPhase
|
|
725
|
+
\`\`\`typescript
|
|
726
|
+
{
|
|
727
|
+
name: string, // "Base", "Build", "Peak", "Taper"
|
|
728
|
+
startWeek: number, // Starting week number
|
|
729
|
+
endWeek: number, // Ending week number
|
|
730
|
+
focus: string, // Phase focus
|
|
731
|
+
weeklyHoursRange: { low: number, high: number },
|
|
732
|
+
keyWorkouts: string[], // Key session types
|
|
733
|
+
physiologicalGoals: string[] // Training adaptations
|
|
734
|
+
}
|
|
735
|
+
\`\`\`
|
|
736
|
+
|
|
737
|
+
### AthleteZones
|
|
738
|
+
\`\`\`typescript
|
|
739
|
+
{
|
|
740
|
+
run?: {
|
|
741
|
+
hr?: { lthr: number, zones: HRZone[] },
|
|
742
|
+
pace?: { thresholdPace: string, thresholdPaceSeconds: number, zones: PaceZone[] }
|
|
743
|
+
},
|
|
744
|
+
bike?: {
|
|
745
|
+
hr?: { lthr: number, zones: HRZone[] },
|
|
746
|
+
power?: { ftp: number, zones: PowerZone[] }
|
|
747
|
+
},
|
|
748
|
+
swim?: {
|
|
749
|
+
css: string, // "1:45/100m"
|
|
750
|
+
cssSeconds: number, // Per 100m
|
|
751
|
+
zones: SwimZone[]
|
|
752
|
+
},
|
|
753
|
+
maxHR?: number,
|
|
754
|
+
restingHR?: number,
|
|
755
|
+
weight?: number // kg
|
|
756
|
+
}
|
|
757
|
+
\`\`\`
|
|
758
|
+
|
|
759
|
+
### RaceStrategy
|
|
760
|
+
\`\`\`typescript
|
|
761
|
+
{
|
|
762
|
+
event: {
|
|
763
|
+
name: string,
|
|
764
|
+
date: string,
|
|
765
|
+
type: string,
|
|
766
|
+
distances?: { swim?: number, bike?: number, run?: number }
|
|
767
|
+
},
|
|
768
|
+
pacing: {
|
|
769
|
+
swim?: { target: string, notes: string },
|
|
770
|
+
bike?: { targetPower: string, targetHR: string, notes: string },
|
|
771
|
+
run?: { targetPace: string, targetHR: string, notes: string }
|
|
772
|
+
},
|
|
773
|
+
nutrition: {
|
|
774
|
+
preRace: string,
|
|
775
|
+
during: { carbsPerHour: number, fluidPerHour: string, products: string[] },
|
|
776
|
+
notes: string
|
|
777
|
+
},
|
|
778
|
+
taper: {
|
|
779
|
+
startDate: string,
|
|
780
|
+
volumeReduction: number, // Percentage
|
|
781
|
+
notes: string
|
|
782
|
+
},
|
|
783
|
+
raceDay?: {
|
|
784
|
+
wakeUpTime?: string,
|
|
785
|
+
preRaceMeal?: string,
|
|
786
|
+
warmUp?: string,
|
|
787
|
+
mentalCues?: string[]
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
\`\`\`
|
|
791
|
+
|
|
792
|
+
## Common Validation Errors
|
|
793
|
+
|
|
794
|
+
1. **Date format**: All dates must be "YYYY-MM-DD" (e.g., "2025-11-03")
|
|
795
|
+
2. **Missing completed**: Every workout must have \`completed: false\`
|
|
796
|
+
3. **Invalid sport**: Must use exact enum values, case-sensitive
|
|
797
|
+
4. **Missing required fields**: Check all required fields are present
|
|
798
|
+
5. **Invalid version**: Must be exactly "1.0"
|
|
799
|
+
|
|
800
|
+
## Validation
|
|
801
|
+
|
|
802
|
+
Use these commands to validate your plan:
|
|
803
|
+
|
|
804
|
+
\`\`\`bash
|
|
805
|
+
# Validate only
|
|
806
|
+
npx endurance-coach validate plan.json
|
|
807
|
+
|
|
808
|
+
# Render (includes validation)
|
|
809
|
+
npx endurance-coach render plan.json --output plan.html
|
|
810
|
+
\`\`\`
|
|
811
|
+
`);
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Extract plan ID, changes, and completed status from backup localStorage data
|
|
815
|
+
*/
|
|
816
|
+
function extractDataFromBackup(backupData) {
|
|
817
|
+
// Find the changes key (format: "plan-{id}-changes")
|
|
818
|
+
const changesKey = Object.keys(backupData).find((key) => key.endsWith("-changes"));
|
|
819
|
+
if (!changesKey) {
|
|
820
|
+
return { planId: null, changes: null, completed: null };
|
|
821
|
+
}
|
|
822
|
+
// Extract plan ID from key
|
|
823
|
+
const planId = changesKey.replace(/^plan-/, "").replace(/-changes$/, "");
|
|
824
|
+
// Parse the changes JSON
|
|
825
|
+
let changes = null;
|
|
826
|
+
try {
|
|
827
|
+
const changesJson = backupData[changesKey];
|
|
828
|
+
changes = JSON.parse(changesJson);
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
console.error("Failed to parse changes:", error);
|
|
832
|
+
}
|
|
833
|
+
// Find and parse completed workouts
|
|
834
|
+
const completedKey = `plan-${planId}-completed`;
|
|
835
|
+
let completed = null;
|
|
836
|
+
if (backupData[completedKey]) {
|
|
837
|
+
try {
|
|
838
|
+
completed = JSON.parse(backupData[completedKey]);
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
console.error("Failed to parse completed data:", error);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return { planId, changes, completed };
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Apply completed status to workouts in the plan
|
|
848
|
+
*/
|
|
849
|
+
function applyCompletedStatus(plan, completed) {
|
|
850
|
+
const completedCount = Object.keys(completed).filter((id) => completed[id]).length;
|
|
851
|
+
console.log(`Applying completed status to ${completedCount} workouts...`);
|
|
852
|
+
let appliedCount = 0;
|
|
853
|
+
plan.weeks?.forEach((week) => {
|
|
854
|
+
week.days?.forEach((day) => {
|
|
855
|
+
day.workouts?.forEach((workout) => {
|
|
856
|
+
if (completed[workout.id] !== undefined) {
|
|
857
|
+
workout.completed = completed[workout.id];
|
|
858
|
+
if (completed[workout.id]) {
|
|
859
|
+
appliedCount++;
|
|
860
|
+
console.log(` - Marked ${workout.id} as completed`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
if (appliedCount > 0) {
|
|
867
|
+
console.log(`Applied completed status to ${appliedCount} workouts`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Apply changes to the training plan
|
|
872
|
+
*/
|
|
873
|
+
function applyChangesToPlan(plan, changes) {
|
|
874
|
+
const modifiedPlan = JSON.parse(JSON.stringify(plan));
|
|
875
|
+
// Track all workouts by ID for easy lookup
|
|
876
|
+
const workoutMap = new Map();
|
|
877
|
+
modifiedPlan.weeks?.forEach((week, weekIdx) => {
|
|
878
|
+
week.days?.forEach((day, dayIdx) => {
|
|
879
|
+
day.workouts?.forEach((workout, workoutIdx) => {
|
|
880
|
+
workoutMap.set(workout.id, { weekIdx, dayIdx, workoutIdx });
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
// 1. Apply deleted workouts
|
|
885
|
+
console.log(`Applying ${changes.deleted.length} deletions...`);
|
|
886
|
+
changes.deleted.forEach((workoutId) => {
|
|
887
|
+
const location = workoutMap.get(workoutId);
|
|
888
|
+
if (location) {
|
|
889
|
+
const { weekIdx, dayIdx, workoutIdx } = location;
|
|
890
|
+
modifiedPlan.weeks[weekIdx].days[dayIdx].workouts.splice(workoutIdx, 1);
|
|
891
|
+
console.log(` - Deleted workout: ${workoutId}`);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
// Rebuild workout map after deletions
|
|
895
|
+
workoutMap.clear();
|
|
896
|
+
modifiedPlan.weeks?.forEach((week, weekIdx) => {
|
|
897
|
+
week.days?.forEach((day, dayIdx) => {
|
|
898
|
+
day.workouts?.forEach((workout, workoutIdx) => {
|
|
899
|
+
workoutMap.set(workout.id, { weekIdx, dayIdx, workoutIdx });
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
// 2. Apply edits to existing workouts
|
|
904
|
+
const editCount = Object.keys(changes.edited).length;
|
|
905
|
+
console.log(`Applying ${editCount} edits...`);
|
|
906
|
+
Object.entries(changes.edited).forEach(([workoutId, edits]) => {
|
|
907
|
+
const location = workoutMap.get(workoutId);
|
|
908
|
+
if (location) {
|
|
909
|
+
const { weekIdx, dayIdx, workoutIdx } = location;
|
|
910
|
+
const workout = modifiedPlan.weeks[weekIdx].days[dayIdx].workouts[workoutIdx];
|
|
911
|
+
Object.assign(workout, edits);
|
|
912
|
+
console.log(` - Edited workout: ${workoutId}`);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
// 3. Apply moved workouts
|
|
916
|
+
const moveCount = Object.keys(changes.moved).length;
|
|
917
|
+
console.log(`Applying ${moveCount} moves...`);
|
|
918
|
+
Object.entries(changes.moved).forEach(([workoutId, newDate]) => {
|
|
919
|
+
const location = workoutMap.get(workoutId);
|
|
920
|
+
if (!location)
|
|
921
|
+
return;
|
|
922
|
+
const { weekIdx, dayIdx, workoutIdx } = location;
|
|
923
|
+
// Remove workout from original location
|
|
924
|
+
const [workout] = modifiedPlan.weeks[weekIdx].days[dayIdx].workouts.splice(workoutIdx, 1);
|
|
925
|
+
// Find the target day
|
|
926
|
+
let targetDay = null;
|
|
927
|
+
let targetWeekIdx = -1;
|
|
928
|
+
let targetDayIdx = -1;
|
|
929
|
+
for (let wIdx = 0; wIdx < modifiedPlan.weeks.length; wIdx++) {
|
|
930
|
+
const week = modifiedPlan.weeks[wIdx];
|
|
931
|
+
for (let dIdx = 0; dIdx < week.days.length; dIdx++) {
|
|
932
|
+
const day = week.days[dIdx];
|
|
933
|
+
if (day.date === newDate) {
|
|
934
|
+
targetDay = day;
|
|
935
|
+
targetWeekIdx = wIdx;
|
|
936
|
+
targetDayIdx = dIdx;
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (targetDay)
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
if (targetDay) {
|
|
944
|
+
// Add workout to new location
|
|
945
|
+
if (!targetDay.workouts) {
|
|
946
|
+
targetDay.workouts = [];
|
|
947
|
+
}
|
|
948
|
+
targetDay.workouts.push(workout);
|
|
949
|
+
console.log(` - Moved workout ${workoutId} to ${newDate}`);
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
console.warn(` ! Could not find target date ${newDate} for workout ${workoutId}`);
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
// 4. Add new workouts
|
|
956
|
+
const addCount = Object.keys(changes.added).length;
|
|
957
|
+
console.log(`Adding ${addCount} new workouts...`);
|
|
958
|
+
Object.entries(changes.added).forEach(([workoutId, { date, workout }]) => {
|
|
959
|
+
// Find the target day
|
|
960
|
+
let targetDay = null;
|
|
961
|
+
for (const week of modifiedPlan.weeks || []) {
|
|
962
|
+
for (const day of week.days || []) {
|
|
963
|
+
if (day.date === date) {
|
|
964
|
+
targetDay = day;
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (targetDay)
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
if (targetDay) {
|
|
972
|
+
if (!targetDay.workouts) {
|
|
973
|
+
targetDay.workouts = [];
|
|
974
|
+
}
|
|
975
|
+
targetDay.workouts.push(workout);
|
|
976
|
+
console.log(` - Added workout ${workoutId} on ${date}`);
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
console.warn(` ! Could not find date ${date} for new workout ${workoutId}`);
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
// Update the plan's updatedAt timestamp
|
|
983
|
+
modifiedPlan.meta.updatedAt = new Date().toISOString();
|
|
984
|
+
return modifiedPlan;
|
|
985
|
+
}
|
|
986
|
+
export function modifyCommand(options) {
|
|
987
|
+
console.log("š Modifying training plan...\n");
|
|
988
|
+
try {
|
|
989
|
+
// 1. Read backup file
|
|
990
|
+
console.log(`Reading backup: ${options.backup}`);
|
|
991
|
+
const backupContent = readFileSync(options.backup, "utf-8");
|
|
992
|
+
const backupData = JSON.parse(backupContent);
|
|
993
|
+
// 2. Extract changes and completed status from backup
|
|
994
|
+
const { planId, changes, completed } = extractDataFromBackup(backupData);
|
|
995
|
+
if (!changes) {
|
|
996
|
+
console.error("ā No changes found in backup file");
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
console.log(`Found data for plan: ${planId}`);
|
|
1000
|
+
if (completed) {
|
|
1001
|
+
const completedCount = Object.keys(completed).filter((id) => completed[id]).length;
|
|
1002
|
+
console.log(`Found ${completedCount} completed workouts in backup`);
|
|
1003
|
+
}
|
|
1004
|
+
console.log();
|
|
1005
|
+
// 3. Read plan file
|
|
1006
|
+
console.log(`Reading plan: ${options.plan}`);
|
|
1007
|
+
const planContent = readFileSync(options.plan, "utf-8");
|
|
1008
|
+
const plan = JSON.parse(planContent);
|
|
1009
|
+
// Verify plan IDs match
|
|
1010
|
+
if (plan.meta.id !== planId) {
|
|
1011
|
+
console.warn(`ā ļø Warning: Plan ID mismatch!\n Backup: ${planId}\n Plan: ${plan.meta.id}`);
|
|
1012
|
+
console.log(" Continuing anyway...\n");
|
|
1013
|
+
}
|
|
1014
|
+
// 4. Apply changes
|
|
1015
|
+
console.log("Applying changes:\n");
|
|
1016
|
+
const modifiedPlan = applyChangesToPlan(plan, changes);
|
|
1017
|
+
// 5. Apply completed status if available
|
|
1018
|
+
if (completed) {
|
|
1019
|
+
console.log();
|
|
1020
|
+
applyCompletedStatus(modifiedPlan, completed);
|
|
1021
|
+
}
|
|
1022
|
+
// 6. Write output
|
|
1023
|
+
const outputPath = options.output || options.plan;
|
|
1024
|
+
console.log(`\nWriting modified plan to: ${outputPath}`);
|
|
1025
|
+
writeFileSync(outputPath, JSON.stringify(modifiedPlan, null, 2));
|
|
1026
|
+
console.log("\nā
Plan modified successfully!");
|
|
1027
|
+
console.log(`\nSummary:`);
|
|
1028
|
+
console.log(` - Deleted: ${changes.deleted.length} workouts`);
|
|
1029
|
+
console.log(` - Edited: ${Object.keys(changes.edited).length} workouts`);
|
|
1030
|
+
console.log(` - Moved: ${Object.keys(changes.moved).length} workouts`);
|
|
1031
|
+
console.log(` - Added: ${Object.keys(changes.added).length} workouts`);
|
|
1032
|
+
if (completed) {
|
|
1033
|
+
const completedCount = Object.keys(completed).filter((id) => completed[id]).length;
|
|
1034
|
+
console.log(` - Completed: ${completedCount} workouts marked as done`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
console.error("ā Error modifying plan:", error instanceof Error ? error.message : error);
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
// ============================================================================
|
|
1043
|
+
// Main
|
|
1044
|
+
// ============================================================================
|
|
1045
|
+
async function main() {
|
|
1046
|
+
const args = parseArgs();
|
|
1047
|
+
switch (args.command) {
|
|
1048
|
+
case "help":
|
|
1049
|
+
printHelp();
|
|
1050
|
+
break;
|
|
1051
|
+
case "auth":
|
|
1052
|
+
await runAuth(args);
|
|
1053
|
+
break;
|
|
1054
|
+
case "sync":
|
|
1055
|
+
await runSync(args);
|
|
1056
|
+
break;
|
|
1057
|
+
case "schema":
|
|
1058
|
+
runSchema();
|
|
1059
|
+
break;
|
|
1060
|
+
case "validate":
|
|
1061
|
+
runValidate(args);
|
|
1062
|
+
break;
|
|
1063
|
+
case "render":
|
|
1064
|
+
runRender(args);
|
|
1065
|
+
break;
|
|
1066
|
+
case "query":
|
|
1067
|
+
await runQuery(args);
|
|
1068
|
+
break;
|
|
1069
|
+
case "modify":
|
|
1070
|
+
modifyCommand(args);
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
main().catch((err) => {
|
|
1075
|
+
log.error(err.message);
|
|
1076
|
+
process.exit(1);
|
|
1077
|
+
});
|