@robinthues/rt-claude-coach 0.1.2 → 0.3.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/dist/backup/dump.d.ts +17 -0
- package/dist/backup/dump.js +118 -0
- package/dist/backup/index.d.ts +11 -0
- package/dist/backup/index.js +202 -0
- package/dist/backup/lock.d.ts +25 -0
- package/dist/backup/lock.js +187 -0
- package/dist/backup/repo.d.ts +20 -0
- package/dist/backup/repo.js +70 -0
- package/dist/backup/restore.d.ts +33 -0
- package/dist/backup/restore.js +102 -0
- package/dist/calendar/dates.d.ts +24 -0
- package/dist/calendar/dates.js +57 -0
- package/dist/calendar/index.d.ts +3 -0
- package/dist/calendar/index.js +2 -0
- package/dist/calendar/parse.d.ts +19 -0
- package/dist/calendar/parse.js +74 -0
- package/dist/calendar/types.d.ts +70 -0
- package/dist/calendar/types.js +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +272 -5
- package/dist/db/client.js +4 -0
- package/dist/db/schema.sql +64 -0
- package/dist/db/sql.d.ts +4 -0
- package/dist/db/sql.js +10 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +23 -17
- package/dist/recovery/dates.d.ts +12 -0
- package/dist/recovery/dates.js +31 -0
- package/dist/recovery/index.d.ts +4 -0
- package/dist/recovery/index.js +3 -0
- package/dist/recovery/parse.d.ts +21 -0
- package/dist/recovery/parse.js +128 -0
- package/dist/recovery/store.d.ts +14 -0
- package/dist/recovery/store.js +108 -0
- package/dist/recovery/types.d.ts +50 -0
- package/dist/recovery/types.js +1 -0
- package/dist/strava/store.d.ts +2 -1
- package/dist/strava/store.js +2 -5
- package/dist/viewer/lib/completion.d.ts +5 -0
- package/dist/viewer/lib/completion.js +15 -0
- package/dist/viewer/stores/plan.d.ts +0 -1
- package/dist/viewer/stores/plan.js +6 -7
- package/package.json +1 -1
- package/templates/plan-viewer.html +20 -20
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, createConfig, } from "./lib/config.js";
|
|
1
|
+
import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, getDataDir, createConfig, } from "./lib/config.js";
|
|
2
2
|
import { log } from "./lib/logging.js";
|
|
3
3
|
import { migrate } from "./db/migrate.js";
|
|
4
4
|
import { execute, initDatabase, query, queryJson } from "./db/client.js";
|
|
@@ -7,6 +7,10 @@ import { getAllActivities, getAthlete } from "./strava/api.js";
|
|
|
7
7
|
import { insertAthlete, upsertSummaryActivity } from "./strava/store.js";
|
|
8
8
|
import { syncActivityDetails } from "./strava/details.js";
|
|
9
9
|
import { convertPlan } from "./garmin/index.js";
|
|
10
|
+
import { buildFetchList, getDateStatuses, lastNDates, parseRecoveryDay, upsertRecoveryDay, } from "./recovery/index.js";
|
|
11
|
+
import { buildAvailability, isIsoDate } from "./calendar/index.js";
|
|
12
|
+
import { runBackup, runInit } from "./backup/index.js";
|
|
13
|
+
import { runRestore } from "./backup/restore.js";
|
|
10
14
|
import { readFileSync, writeFileSync } from "fs";
|
|
11
15
|
import { dirname, join } from "path";
|
|
12
16
|
import { fileURLToPath } from "url";
|
|
@@ -23,6 +27,27 @@ const proxyUrl = process.env.HTTPS_PROXY ||
|
|
|
23
27
|
if (proxyUrl) {
|
|
24
28
|
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
|
25
29
|
}
|
|
30
|
+
export const DEFAULT_BACKUP_REPO = "RobinThues/claude-coach-data";
|
|
31
|
+
export function parseBackupArgs(args) {
|
|
32
|
+
const parsed = {
|
|
33
|
+
command: "backup",
|
|
34
|
+
init: false,
|
|
35
|
+
push: true,
|
|
36
|
+
quiet: false,
|
|
37
|
+
repo: DEFAULT_BACKUP_REPO,
|
|
38
|
+
};
|
|
39
|
+
for (const arg of args.slice(1)) {
|
|
40
|
+
if (arg === "--init")
|
|
41
|
+
parsed.init = true;
|
|
42
|
+
else if (arg === "--no-push")
|
|
43
|
+
parsed.push = false;
|
|
44
|
+
else if (arg === "--quiet")
|
|
45
|
+
parsed.quiet = true;
|
|
46
|
+
else if (arg.startsWith("--repo="))
|
|
47
|
+
parsed.repo = arg.slice("--repo=".length);
|
|
48
|
+
}
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
26
51
|
function parseArgs() {
|
|
27
52
|
const args = process.argv.slice(2);
|
|
28
53
|
if (args.length === 0 || args[0] === "sync") {
|
|
@@ -96,6 +121,42 @@ function parseArgs() {
|
|
|
96
121
|
}
|
|
97
122
|
return exportArgs;
|
|
98
123
|
}
|
|
124
|
+
if (args[0] === "garmin-status") {
|
|
125
|
+
const statusArgs = {
|
|
126
|
+
command: "garmin-status",
|
|
127
|
+
days: 14,
|
|
128
|
+
json: args.includes("--json"),
|
|
129
|
+
};
|
|
130
|
+
for (const arg of args) {
|
|
131
|
+
if (arg.startsWith("--days=")) {
|
|
132
|
+
const parsed = parseInt(arg.slice("--days=".length), 10);
|
|
133
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
134
|
+
log.error("--days must be a positive integer");
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
statusArgs.days = parsed;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return statusArgs;
|
|
141
|
+
}
|
|
142
|
+
if (args[0] === "import-garmin") {
|
|
143
|
+
if (!args[1]) {
|
|
144
|
+
log.error("import-garmin command requires an input JSON file");
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
return { command: "import-garmin", inputFile: args[1] };
|
|
148
|
+
}
|
|
149
|
+
if (args[0] === "calendar-availability") {
|
|
150
|
+
if (!args[1]) {
|
|
151
|
+
log.error("calendar-availability command requires an input JSON file");
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
command: "calendar-availability",
|
|
156
|
+
inputFile: args[1],
|
|
157
|
+
json: args.includes("--json"),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
99
160
|
if (args[0] === "query") {
|
|
100
161
|
if (!args[1]) {
|
|
101
162
|
log.error("query command requires a SQL statement");
|
|
@@ -123,6 +184,19 @@ function parseArgs() {
|
|
|
123
184
|
}
|
|
124
185
|
return authArgs;
|
|
125
186
|
}
|
|
187
|
+
if (args[0] === "backup") {
|
|
188
|
+
return parseBackupArgs(args);
|
|
189
|
+
}
|
|
190
|
+
if (args[0] === "restore-db") {
|
|
191
|
+
const parsed = { command: "restore-db", force: false };
|
|
192
|
+
for (const arg of args.slice(1)) {
|
|
193
|
+
if (arg === "--force")
|
|
194
|
+
parsed.force = true;
|
|
195
|
+
else if (arg.startsWith("--dump="))
|
|
196
|
+
parsed.dumpFile = arg.slice("--dump=".length);
|
|
197
|
+
}
|
|
198
|
+
return parsed;
|
|
199
|
+
}
|
|
126
200
|
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
127
201
|
return { command: "help" };
|
|
128
202
|
}
|
|
@@ -140,7 +214,12 @@ Commands:
|
|
|
140
214
|
auth Get Strava authorization URL or exchange code for tokens
|
|
141
215
|
render <file> Render a training plan JSON to HTML
|
|
142
216
|
export-garmin <file> Convert a plan JSON to Garmin sync workouts
|
|
217
|
+
garmin-status Show which dates have Garmin recovery data, and what to fetch
|
|
218
|
+
import-garmin <file> Import Garmin recovery MCP responses into the database
|
|
219
|
+
calendar-availability <file> Normalize calendar MCP events into per-day busy blocks
|
|
143
220
|
query <sql> Run a SQL query against the database
|
|
221
|
+
backup Commit the coach data directory and push it to the backup repo
|
|
222
|
+
restore-db Rebuild coach.db from backup/coach-db.sql
|
|
144
223
|
help Show this help message
|
|
145
224
|
|
|
146
225
|
Auth Options (for headless/Claude environments):
|
|
@@ -167,9 +246,26 @@ Export Garmin Options:
|
|
|
167
246
|
--workout=ID Export only the workout with this id
|
|
168
247
|
--date=YYYY-MM-DD Export only workouts on this date
|
|
169
248
|
|
|
249
|
+
Garmin Status Options:
|
|
250
|
+
--days=N Days of history to check (default: 14)
|
|
251
|
+
--json Output as JSON, including the computed fetch list
|
|
252
|
+
|
|
253
|
+
Calendar Availability Options:
|
|
254
|
+
--json Output as JSON (default: plain per-day text)
|
|
255
|
+
|
|
170
256
|
Query Options:
|
|
171
257
|
--json Output as JSON (default: plain text)
|
|
172
258
|
|
|
259
|
+
Backup Options:
|
|
260
|
+
--init One-time setup: git init, .gitignore, RESTORE.md, remote
|
|
261
|
+
--no-push Commit locally without pushing
|
|
262
|
+
--quiet Suppress non-error output (used by the Claude Code hook)
|
|
263
|
+
--repo=OWNER/NAME Override the backup repo (default: RobinThues/claude-coach-data)
|
|
264
|
+
|
|
265
|
+
Restore Options:
|
|
266
|
+
--dump=PATH Read the dump from PATH instead of backup/coach-db.sql
|
|
267
|
+
--force Move an existing coach.db to coach.db.bak, then restore
|
|
268
|
+
|
|
173
269
|
Examples:
|
|
174
270
|
# Headless auth flow (for Claude/automated environments)
|
|
175
271
|
npx @robinthues/rt-claude-coach auth --client-id=12345 --client-secret=abc123
|
|
@@ -192,6 +288,18 @@ Examples:
|
|
|
192
288
|
|
|
193
289
|
# Query the database
|
|
194
290
|
npx @robinthues/rt-claude-coach query "SELECT * FROM weekly_volume LIMIT 5"
|
|
291
|
+
|
|
292
|
+
# Which recovery dates are missing, and what should be fetched?
|
|
293
|
+
npx @robinthues/rt-claude-coach garmin-status --days=14 --json
|
|
294
|
+
|
|
295
|
+
# Import verbatim Garmin MCP responses
|
|
296
|
+
npx @robinthues/rt-claude-coach import-garmin garmin-recovery.json
|
|
297
|
+
|
|
298
|
+
# Normalize verbatim calendar MCP events into per-day availability
|
|
299
|
+
npx @robinthues/rt-claude-coach calendar-availability calendar-events.json --json
|
|
300
|
+
|
|
301
|
+
# Read recovery data back
|
|
302
|
+
npx @robinthues/rt-claude-coach query "SELECT * FROM recovery_recent LIMIT 14"
|
|
195
303
|
`);
|
|
196
304
|
}
|
|
197
305
|
// ============================================================================
|
|
@@ -485,6 +593,125 @@ function runExportGarmin(args) {
|
|
|
485
593
|
log.success(`Wrote ${entries.length} Garmin workout entries to: ${outputFile}`);
|
|
486
594
|
}
|
|
487
595
|
// ============================================================================
|
|
596
|
+
// Garmin Recovery Commands
|
|
597
|
+
// ============================================================================
|
|
598
|
+
async function runGarminStatus(args) {
|
|
599
|
+
await initDatabase();
|
|
600
|
+
migrate();
|
|
601
|
+
const dates = lastNDates(args.days);
|
|
602
|
+
const statuses = getDateStatuses(dates);
|
|
603
|
+
const today = dates[dates.length - 1];
|
|
604
|
+
const yesterday = lastNDates(2)[0];
|
|
605
|
+
const fetch = buildFetchList(statuses, today, yesterday);
|
|
606
|
+
if (args.json) {
|
|
607
|
+
console.log(JSON.stringify({ dates: statuses, fetch }, null, 2));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
for (const { date, status } of statuses) {
|
|
611
|
+
console.log(`${date} ${status}`);
|
|
612
|
+
}
|
|
613
|
+
console.log(`\nFetch these ${fetch.length} date(s): ${fetch.join(", ")}`);
|
|
614
|
+
}
|
|
615
|
+
async function runImportGarmin(args) {
|
|
616
|
+
let fileContents;
|
|
617
|
+
try {
|
|
618
|
+
fileContents = readFileSync(args.inputFile, "utf-8");
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
let file;
|
|
625
|
+
try {
|
|
626
|
+
file = JSON.parse(fileContents);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
log.error("Input file is not valid JSON");
|
|
630
|
+
process.exit(1);
|
|
631
|
+
}
|
|
632
|
+
if (typeof file !== "object" || file === null || !Array.isArray(file.days)) {
|
|
633
|
+
log.error('Input file must be an object with a "days" array');
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
await initDatabase();
|
|
637
|
+
migrate();
|
|
638
|
+
const failures = [];
|
|
639
|
+
let imported = 0;
|
|
640
|
+
for (const day of file.days) {
|
|
641
|
+
try {
|
|
642
|
+
upsertRecoveryDay(parseRecoveryDay(day));
|
|
643
|
+
console.log(` ${day.date ?? "(no date)"} imported`);
|
|
644
|
+
imported++;
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
const date = day.date ?? "(no date)";
|
|
648
|
+
const reason = err.message;
|
|
649
|
+
console.log(` ${date} FAILED — ${reason}`);
|
|
650
|
+
failures.push({ date, reason });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (failures.length === 0) {
|
|
654
|
+
log.success(`Imported ${imported} of ${file.days.length} date(s)`);
|
|
655
|
+
}
|
|
656
|
+
else if (imported > 0) {
|
|
657
|
+
log.warn(`Imported ${imported} of ${file.days.length} date(s)`);
|
|
658
|
+
}
|
|
659
|
+
if (failures.length > 0) {
|
|
660
|
+
log.error(`${failures.length} date(s) failed; successfully parsed dates were still committed`);
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// ============================================================================
|
|
665
|
+
// Calendar Availability Command
|
|
666
|
+
// ============================================================================
|
|
667
|
+
function runCalendarAvailability(args) {
|
|
668
|
+
let fileContents;
|
|
669
|
+
try {
|
|
670
|
+
fileContents = readFileSync(args.inputFile, "utf-8");
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
let input;
|
|
677
|
+
try {
|
|
678
|
+
input = JSON.parse(fileContents);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
log.error("Input file is not valid JSON");
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
if (typeof input !== "object" ||
|
|
685
|
+
input === null ||
|
|
686
|
+
typeof input.timeZone !== "string" ||
|
|
687
|
+
typeof input.window !== "object" ||
|
|
688
|
+
input.window === null ||
|
|
689
|
+
typeof input.window.start !== "string" ||
|
|
690
|
+
typeof input.window.end !== "string" ||
|
|
691
|
+
!Array.isArray(input.calendars)) {
|
|
692
|
+
log.error('Input file must have "timeZone", "window" {start,end}, and a "calendars" array');
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
if (!isIsoDate(input.window.start) || !isIsoDate(input.window.end)) {
|
|
696
|
+
log.error('Input file "window.start" and "window.end" must be YYYY-MM-DD dates');
|
|
697
|
+
process.exit(1);
|
|
698
|
+
}
|
|
699
|
+
const output = buildAvailability(input);
|
|
700
|
+
if (args.json) {
|
|
701
|
+
console.log(JSON.stringify(output, null, 2));
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
for (const day of output.days) {
|
|
705
|
+
const parts = [];
|
|
706
|
+
for (const e of day.allDayEvents)
|
|
707
|
+
parts.push(`[all-day] ${e.title}`);
|
|
708
|
+
for (const b of day.busyBlocks)
|
|
709
|
+
parts.push(`${b.start}-${b.end} ${b.title}`);
|
|
710
|
+
const label = parts.length > 0 ? parts.join("; ") : "clear";
|
|
711
|
+
console.log(`${day.date} ${day.weekday.padEnd(9)} ${label}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// ============================================================================
|
|
488
715
|
// Query Command
|
|
489
716
|
// ============================================================================
|
|
490
717
|
async function runQuery(args) {
|
|
@@ -499,6 +726,24 @@ async function runQuery(args) {
|
|
|
499
726
|
}
|
|
500
727
|
}
|
|
501
728
|
// ============================================================================
|
|
729
|
+
// Backup / Restore Commands
|
|
730
|
+
// ============================================================================
|
|
731
|
+
async function runBackupCommand(args) {
|
|
732
|
+
const code = args.init
|
|
733
|
+
? await runInit({ repo: args.repo, quiet: args.quiet })
|
|
734
|
+
: await runBackup({ push: args.push, quiet: args.quiet });
|
|
735
|
+
process.exit(code);
|
|
736
|
+
}
|
|
737
|
+
function runRestoreDb(args) {
|
|
738
|
+
const code = runRestore({
|
|
739
|
+
dbPath: getDbPath(),
|
|
740
|
+
dumpPath: args.dumpFile ?? join(getDataDir(), "backup", "coach-db.sql"),
|
|
741
|
+
schemaPath: join(__dirname, "db", "schema.sql"),
|
|
742
|
+
force: args.force,
|
|
743
|
+
});
|
|
744
|
+
process.exit(code);
|
|
745
|
+
}
|
|
746
|
+
// ============================================================================
|
|
502
747
|
// Main
|
|
503
748
|
// ============================================================================
|
|
504
749
|
async function main() {
|
|
@@ -519,12 +764,34 @@ async function main() {
|
|
|
519
764
|
case "export-garmin":
|
|
520
765
|
runExportGarmin(args);
|
|
521
766
|
break;
|
|
767
|
+
case "garmin-status":
|
|
768
|
+
await runGarminStatus(args);
|
|
769
|
+
break;
|
|
770
|
+
case "import-garmin":
|
|
771
|
+
await runImportGarmin(args);
|
|
772
|
+
break;
|
|
773
|
+
case "calendar-availability":
|
|
774
|
+
runCalendarAvailability(args);
|
|
775
|
+
break;
|
|
522
776
|
case "query":
|
|
523
777
|
await runQuery(args);
|
|
524
778
|
break;
|
|
779
|
+
case "backup":
|
|
780
|
+
await runBackupCommand(args);
|
|
781
|
+
break;
|
|
782
|
+
case "restore-db":
|
|
783
|
+
runRestoreDb(args);
|
|
784
|
+
break;
|
|
525
785
|
}
|
|
526
786
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
787
|
+
// Vitest sets VITEST=true in every test process. Importing this module for
|
|
788
|
+
// its exported parsers (parseBackupArgs, etc.) must not also run the CLI
|
|
789
|
+
// against the real environment, so skip main() there. Real invocations
|
|
790
|
+
// (tsx src/cli.ts, or bin/claude-coach.js dynamically importing dist/cli.js)
|
|
791
|
+
// never set this variable.
|
|
792
|
+
if (process.env.VITEST !== "true") {
|
|
793
|
+
main().catch((err) => {
|
|
794
|
+
log.error(err.message);
|
|
795
|
+
process.exit(1);
|
|
796
|
+
});
|
|
797
|
+
}
|
package/dist/db/client.js
CHANGED
|
@@ -12,6 +12,10 @@ async function detectBackend() {
|
|
|
12
12
|
const sqlite = await import("node:sqlite");
|
|
13
13
|
const dbPath = getDbPath();
|
|
14
14
|
const db = new sqlite.DatabaseSync(dbPath);
|
|
15
|
+
// A backup dump holds a read-only connection open on this same file. A
|
|
16
|
+
// coach write during that window would otherwise fail at once with
|
|
17
|
+
// "database is locked" instead of just waiting the dump out.
|
|
18
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
15
19
|
return {
|
|
16
20
|
query(sql) {
|
|
17
21
|
const stmt = db.prepare(sql);
|
package/dist/db/schema.sql
CHANGED
|
@@ -106,3 +106,67 @@ SELECT
|
|
|
106
106
|
FROM activities
|
|
107
107
|
ORDER BY start_date DESC
|
|
108
108
|
LIMIT 50;
|
|
109
|
+
|
|
110
|
+
-- Garmin daily recovery data (one row per calendar date)
|
|
111
|
+
CREATE TABLE IF NOT EXISTS garmin_daily (
|
|
112
|
+
date TEXT PRIMARY KEY, -- YYYY-MM-DD, local calendar date
|
|
113
|
+
|
|
114
|
+
-- Training readiness (selected entry; see src/recovery/parse.ts)
|
|
115
|
+
readiness_score INTEGER,
|
|
116
|
+
readiness_level TEXT, -- LOW | MODERATE | HIGH | ...
|
|
117
|
+
readiness_feedback TEXT, -- RESTED_AND_READY, WELL_DONE, ...
|
|
118
|
+
readiness_context TEXT, -- which entry the parser selected
|
|
119
|
+
readiness_timestamp TEXT,
|
|
120
|
+
recovery_time_hours REAL,
|
|
121
|
+
acute_load INTEGER,
|
|
122
|
+
|
|
123
|
+
-- Readiness contributing factors (percent, 0-100)
|
|
124
|
+
sleep_factor_percent INTEGER,
|
|
125
|
+
recovery_factor_percent INTEGER,
|
|
126
|
+
training_load_factor_percent INTEGER,
|
|
127
|
+
hrv_factor_percent INTEGER,
|
|
128
|
+
stress_history_factor_percent INTEGER,
|
|
129
|
+
sleep_history_factor_percent INTEGER,
|
|
130
|
+
|
|
131
|
+
-- HRV
|
|
132
|
+
hrv_last_night_ms INTEGER,
|
|
133
|
+
hrv_weekly_avg_ms INTEGER,
|
|
134
|
+
hrv_status TEXT, -- BALANCED | UNBALANCED | LOW | ...
|
|
135
|
+
hrv_baseline_low_ms INTEGER,
|
|
136
|
+
hrv_baseline_upper_ms INTEGER,
|
|
137
|
+
|
|
138
|
+
-- Sleep
|
|
139
|
+
sleep_seconds INTEGER,
|
|
140
|
+
deep_sleep_seconds INTEGER,
|
|
141
|
+
light_sleep_seconds INTEGER,
|
|
142
|
+
rem_sleep_seconds INTEGER,
|
|
143
|
+
awake_seconds INTEGER,
|
|
144
|
+
sleep_score INTEGER,
|
|
145
|
+
sleep_score_qualifier TEXT, -- EXCELLENT | GOOD | FAIR | POOR
|
|
146
|
+
avg_overnight_hrv REAL,
|
|
147
|
+
avg_sleep_stress REAL,
|
|
148
|
+
|
|
149
|
+
-- Resting heart rate
|
|
150
|
+
resting_hr INTEGER,
|
|
151
|
+
|
|
152
|
+
raw_json TEXT, -- merged raw MCP payloads for this date
|
|
153
|
+
synced_at TEXT DEFAULT (datetime('now'))
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_garmin_daily_date ON garmin_daily(date);
|
|
157
|
+
|
|
158
|
+
DROP VIEW IF EXISTS recovery_recent;
|
|
159
|
+
CREATE VIEW recovery_recent AS
|
|
160
|
+
SELECT
|
|
161
|
+
date,
|
|
162
|
+
readiness_score,
|
|
163
|
+
readiness_level,
|
|
164
|
+
hrv_last_night_ms,
|
|
165
|
+
hrv_weekly_avg_ms,
|
|
166
|
+
hrv_status,
|
|
167
|
+
ROUND(sleep_seconds / 3600.0, 1) AS sleep_hours,
|
|
168
|
+
sleep_score,
|
|
169
|
+
resting_hr
|
|
170
|
+
FROM garmin_daily
|
|
171
|
+
WHERE date >= date('now', '-60 days')
|
|
172
|
+
ORDER BY date DESC;
|
package/dist/db/sql.d.ts
ADDED
package/dist/db/sql.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Quote a value as a SQL string literal, or NULL. */
|
|
2
|
+
export function escapeString(str) {
|
|
3
|
+
if (str == null)
|
|
4
|
+
return "NULL";
|
|
5
|
+
return `'${str.replace(/'/g, "''")}'`;
|
|
6
|
+
}
|
|
7
|
+
/** Render a number as a SQL numeric literal, or NULL. */
|
|
8
|
+
export function numOrNull(value) {
|
|
9
|
+
return value == null ? "NULL" : String(value);
|
|
10
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
package/dist/lib/config.js
CHANGED
|
@@ -2,51 +2,57 @@ import { homedir } from "os";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import * as readline from "readline";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
export function getDataDir() {
|
|
6
|
+
return process.env.CLAUDE_COACH_DIR ?? join(homedir(), ".claude-coach");
|
|
7
|
+
}
|
|
8
|
+
function configFile() {
|
|
9
|
+
return join(getDataDir(), "config.json");
|
|
10
|
+
}
|
|
11
|
+
function tokensFile() {
|
|
12
|
+
return join(getDataDir(), "tokens.json");
|
|
13
|
+
}
|
|
9
14
|
export function ensureConfigDir() {
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
const dataDir = getDataDir();
|
|
16
|
+
if (!existsSync(dataDir)) {
|
|
17
|
+
mkdirSync(dataDir, { recursive: true });
|
|
12
18
|
}
|
|
13
19
|
}
|
|
14
20
|
export function getConfigPath() {
|
|
15
|
-
return
|
|
21
|
+
return configFile();
|
|
16
22
|
}
|
|
17
23
|
export function getTokensPath() {
|
|
18
|
-
return
|
|
24
|
+
return tokensFile();
|
|
19
25
|
}
|
|
20
26
|
export function getDbPath() {
|
|
21
|
-
return process.env.CLAUDE_COACH_DB ??
|
|
27
|
+
return process.env.CLAUDE_COACH_DB ?? join(getDataDir(), "coach.db");
|
|
22
28
|
}
|
|
23
29
|
export function configExists() {
|
|
24
|
-
return existsSync(
|
|
30
|
+
return existsSync(configFile());
|
|
25
31
|
}
|
|
26
32
|
export function tokensExist() {
|
|
27
|
-
return existsSync(
|
|
33
|
+
return existsSync(tokensFile());
|
|
28
34
|
}
|
|
29
35
|
export function loadConfig() {
|
|
30
36
|
if (!configExists()) {
|
|
31
|
-
throw new Error(`Config not found at ${
|
|
37
|
+
throw new Error(`Config not found at ${configFile()}. Run setup first.`);
|
|
32
38
|
}
|
|
33
|
-
const data = readFileSync(
|
|
39
|
+
const data = readFileSync(configFile(), "utf-8");
|
|
34
40
|
return JSON.parse(data);
|
|
35
41
|
}
|
|
36
42
|
export function saveConfig(config) {
|
|
37
43
|
ensureConfigDir();
|
|
38
|
-
writeFileSync(
|
|
44
|
+
writeFileSync(configFile(), JSON.stringify(config, null, 2));
|
|
39
45
|
}
|
|
40
46
|
export function loadTokens() {
|
|
41
47
|
if (!tokensExist()) {
|
|
42
|
-
throw new Error(`Tokens not found at ${
|
|
48
|
+
throw new Error(`Tokens not found at ${tokensFile()}. Run auth first.`);
|
|
43
49
|
}
|
|
44
|
-
const data = readFileSync(
|
|
50
|
+
const data = readFileSync(tokensFile(), "utf-8");
|
|
45
51
|
return JSON.parse(data);
|
|
46
52
|
}
|
|
47
53
|
export function saveTokens(tokens) {
|
|
48
54
|
ensureConfigDir();
|
|
49
|
-
writeFileSync(
|
|
55
|
+
writeFileSync(tokensFile(), JSON.stringify(tokens, null, 2));
|
|
50
56
|
}
|
|
51
57
|
export function tokensExpired(tokens) {
|
|
52
58
|
// Add 60 second buffer
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DateStatusRow } from "./types.js";
|
|
2
|
+
/** Format a Date as a local-calendar YYYY-MM-DD, matching Garmin's day grain. */
|
|
3
|
+
export declare function toLocalIsoDate(d: Date): string;
|
|
4
|
+
/** The last `n` local dates ending with `today`, oldest first. */
|
|
5
|
+
export declare function lastNDates(n: number, today?: Date): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Dates the skill should fetch from the Garmin MCP: anything missing or
|
|
8
|
+
* partial, plus today and yesterday unconditionally — Garmin revises last
|
|
9
|
+
* night's sleep and HRV hours after the fact, so a `present` row for those two
|
|
10
|
+
* days may still be stale.
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildFetchList(statuses: DateStatusRow[], today: string, yesterday: string): string[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Format a Date as a local-calendar YYYY-MM-DD, matching Garmin's day grain. */
|
|
2
|
+
export function toLocalIsoDate(d) {
|
|
3
|
+
const year = d.getFullYear();
|
|
4
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
5
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
6
|
+
return `${year}-${month}-${day}`;
|
|
7
|
+
}
|
|
8
|
+
/** The last `n` local dates ending with `today`, oldest first. */
|
|
9
|
+
export function lastNDates(n, today = new Date()) {
|
|
10
|
+
const dates = [];
|
|
11
|
+
for (let offset = n - 1; offset >= 0; offset--) {
|
|
12
|
+
const d = new Date(today.getTime());
|
|
13
|
+
d.setDate(d.getDate() - offset);
|
|
14
|
+
dates.push(toLocalIsoDate(d));
|
|
15
|
+
}
|
|
16
|
+
return dates;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Dates the skill should fetch from the Garmin MCP: anything missing or
|
|
20
|
+
* partial, plus today and yesterday unconditionally — Garmin revises last
|
|
21
|
+
* night's sleep and HRV hours after the fact, so a `present` row for those two
|
|
22
|
+
* days may still be stale.
|
|
23
|
+
*/
|
|
24
|
+
export function buildFetchList(statuses, today, yesterday) {
|
|
25
|
+
const fetch = new Set([today, yesterday]);
|
|
26
|
+
for (const { date, status } of statuses) {
|
|
27
|
+
if (status !== "present")
|
|
28
|
+
fetch.add(date);
|
|
29
|
+
}
|
|
30
|
+
return [...fetch].sort();
|
|
31
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { unwrapPayload, selectReadinessEntry, parseRecoveryDay } from "./parse.js";
|
|
2
|
+
export { upsertRecoveryDay, getDateStatuses } from "./store.js";
|
|
3
|
+
export { toLocalIsoDate, lastNDates, buildFetchList } from "./dates.js";
|
|
4
|
+
export type { RecoveryDay, RecoveryDayInput, RecoveryImportFile, DateStatus, DateStatusRow, } from "./types.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RecoveryDay, RecoveryDayInput } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Garmin MCP tools return `{ result: "<JSON string>" }` — the payload is a
|
|
4
|
+
* string containing JSON, not an object. Unwrap it, tolerating an already
|
|
5
|
+
* parsed object so a hand-assembled import file also works.
|
|
6
|
+
*/
|
|
7
|
+
export declare function unwrapPayload(payload: unknown): unknown;
|
|
8
|
+
/**
|
|
9
|
+
* Readiness returns several entries per day with different contexts and
|
|
10
|
+
* different scores. Prefer the morning reading: it answers "should I train
|
|
11
|
+
* hard today". The post-exercise reset reflects the session just completed and
|
|
12
|
+
* would double-count fatigue already visible in that day's Strava activity.
|
|
13
|
+
*/
|
|
14
|
+
export declare function selectReadinessEntry(payload: unknown): Record<string, unknown> | null;
|
|
15
|
+
/**
|
|
16
|
+
* Map one day of verbatim MCP payloads onto a flat row. All field mapping
|
|
17
|
+
* lives here so a Garmin field rename fails a test instead of being silently
|
|
18
|
+
* absorbed. Throws on an invalid date or unparseable payload; never on absent
|
|
19
|
+
* data.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseRecoveryDay(input: RecoveryDayInput): RecoveryDay;
|