@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.
Files changed (44) hide show
  1. package/dist/backup/dump.d.ts +17 -0
  2. package/dist/backup/dump.js +118 -0
  3. package/dist/backup/index.d.ts +11 -0
  4. package/dist/backup/index.js +202 -0
  5. package/dist/backup/lock.d.ts +25 -0
  6. package/dist/backup/lock.js +187 -0
  7. package/dist/backup/repo.d.ts +20 -0
  8. package/dist/backup/repo.js +70 -0
  9. package/dist/backup/restore.d.ts +33 -0
  10. package/dist/backup/restore.js +102 -0
  11. package/dist/calendar/dates.d.ts +24 -0
  12. package/dist/calendar/dates.js +57 -0
  13. package/dist/calendar/index.d.ts +3 -0
  14. package/dist/calendar/index.js +2 -0
  15. package/dist/calendar/parse.d.ts +19 -0
  16. package/dist/calendar/parse.js +74 -0
  17. package/dist/calendar/types.d.ts +70 -0
  18. package/dist/calendar/types.js +1 -0
  19. package/dist/cli.d.ts +9 -0
  20. package/dist/cli.js +272 -5
  21. package/dist/db/client.js +4 -0
  22. package/dist/db/schema.sql +64 -0
  23. package/dist/db/sql.d.ts +4 -0
  24. package/dist/db/sql.js +10 -0
  25. package/dist/lib/config.d.ts +1 -0
  26. package/dist/lib/config.js +23 -17
  27. package/dist/recovery/dates.d.ts +12 -0
  28. package/dist/recovery/dates.js +31 -0
  29. package/dist/recovery/index.d.ts +4 -0
  30. package/dist/recovery/index.js +3 -0
  31. package/dist/recovery/parse.d.ts +21 -0
  32. package/dist/recovery/parse.js +128 -0
  33. package/dist/recovery/store.d.ts +14 -0
  34. package/dist/recovery/store.js +108 -0
  35. package/dist/recovery/types.d.ts +50 -0
  36. package/dist/recovery/types.js +1 -0
  37. package/dist/strava/store.d.ts +2 -1
  38. package/dist/strava/store.js +2 -5
  39. package/dist/viewer/lib/completion.d.ts +5 -0
  40. package/dist/viewer/lib/completion.js +15 -0
  41. package/dist/viewer/stores/plan.d.ts +0 -1
  42. package/dist/viewer/stores/plan.js +6 -7
  43. package/package.json +1 -1
  44. 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
- main().catch((err) => {
528
- log.error(err.message);
529
- process.exit(1);
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);
@@ -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;
@@ -0,0 +1,4 @@
1
+ /** Quote a value as a SQL string literal, or NULL. */
2
+ export declare function escapeString(str: string | null | undefined): string;
3
+ /** Render a number as a SQL numeric literal, or NULL. */
4
+ export declare function numOrNull(value: number | null | undefined): string;
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
+ }
@@ -1,3 +1,4 @@
1
+ export declare function getDataDir(): string;
1
2
  export interface StravaConfig {
2
3
  client_id: string;
3
4
  client_secret: string;
@@ -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
- const CONFIG_DIR = join(homedir(), ".claude-coach");
6
- const CONFIG_FILE = join(CONFIG_DIR, "config.json");
7
- const TOKENS_FILE = join(CONFIG_DIR, "tokens.json");
8
- const DB_FILE = join(CONFIG_DIR, "coach.db");
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
- if (!existsSync(CONFIG_DIR)) {
11
- mkdirSync(CONFIG_DIR, { recursive: true });
15
+ const dataDir = getDataDir();
16
+ if (!existsSync(dataDir)) {
17
+ mkdirSync(dataDir, { recursive: true });
12
18
  }
13
19
  }
14
20
  export function getConfigPath() {
15
- return CONFIG_FILE;
21
+ return configFile();
16
22
  }
17
23
  export function getTokensPath() {
18
- return TOKENS_FILE;
24
+ return tokensFile();
19
25
  }
20
26
  export function getDbPath() {
21
- return process.env.CLAUDE_COACH_DB ?? DB_FILE;
27
+ return process.env.CLAUDE_COACH_DB ?? join(getDataDir(), "coach.db");
22
28
  }
23
29
  export function configExists() {
24
- return existsSync(CONFIG_FILE);
30
+ return existsSync(configFile());
25
31
  }
26
32
  export function tokensExist() {
27
- return existsSync(TOKENS_FILE);
33
+ return existsSync(tokensFile());
28
34
  }
29
35
  export function loadConfig() {
30
36
  if (!configExists()) {
31
- throw new Error(`Config not found at ${CONFIG_FILE}. Run setup first.`);
37
+ throw new Error(`Config not found at ${configFile()}. Run setup first.`);
32
38
  }
33
- const data = readFileSync(CONFIG_FILE, "utf-8");
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(CONFIG_FILE, JSON.stringify(config, null, 2));
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 ${TOKENS_FILE}. Run auth first.`);
48
+ throw new Error(`Tokens not found at ${tokensFile()}. Run auth first.`);
43
49
  }
44
- const data = readFileSync(TOKENS_FILE, "utf-8");
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(TOKENS_FILE, JSON.stringify(tokens, null, 2));
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,3 @@
1
+ export { unwrapPayload, selectReadinessEntry, parseRecoveryDay } from "./parse.js";
2
+ export { upsertRecoveryDay, getDateStatuses } from "./store.js";
3
+ export { toLocalIsoDate, lastNDates, buildFetchList } from "./dates.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;