@robinthues/rt-claude-coach 0.1.2 → 0.2.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/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.js +188 -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/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/package.json +1 -1
- package/templates/plan-viewer.html +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RawEvent } from "./types.js";
|
|
2
|
+
/** True when an event is all-day (uses `date`, not `dateTime`). */
|
|
3
|
+
export declare function isAllDay(event: RawEvent): boolean;
|
|
4
|
+
/** The YYYY-MM-DD portion of an all-day time value. */
|
|
5
|
+
export declare function allDayDate(value: string): string;
|
|
6
|
+
/** Advance a YYYY-MM-DD string by one day, UTC-safe (no DST drift). */
|
|
7
|
+
export declare function addDay(date: string): string;
|
|
8
|
+
/** True when a string is a YYYY-MM-DD calendar date. */
|
|
9
|
+
export declare function isIsoDate(value: string): boolean;
|
|
10
|
+
/** Enumerate dates from start (inclusive) to end (exclusive). */
|
|
11
|
+
export declare function datesInRange(startInclusive: string, endExclusive: string): string[];
|
|
12
|
+
/** The weekday name for a YYYY-MM-DD date. */
|
|
13
|
+
export declare function weekdayOf(date: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Some ICU builds render midnight on a 24-hour clock as "24"; normalise it to
|
|
16
|
+
* "00" so a wall-clock hour is always in [00, 23]. Defensive against ICU
|
|
17
|
+
* variation across Node runtimes — inert where the clock is already h23.
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeHour(hour: string): string;
|
|
20
|
+
/** Resolve an RFC3339 timed value to wall-clock date + HH:MM in the target zone. */
|
|
21
|
+
export declare function resolveWallClock(dateTime: string, timeZone: string): {
|
|
22
|
+
date: string;
|
|
23
|
+
time: string;
|
|
24
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** True when an event is all-day (uses `date`, not `dateTime`). */
|
|
2
|
+
export function isAllDay(event) {
|
|
3
|
+
return Boolean(event.start?.date) && !event.start?.dateTime;
|
|
4
|
+
}
|
|
5
|
+
/** The YYYY-MM-DD portion of an all-day time value. */
|
|
6
|
+
export function allDayDate(value) {
|
|
7
|
+
return value.slice(0, 10);
|
|
8
|
+
}
|
|
9
|
+
/** Advance a YYYY-MM-DD string by one day, UTC-safe (no DST drift). */
|
|
10
|
+
export function addDay(date) {
|
|
11
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
12
|
+
return new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
|
|
13
|
+
}
|
|
14
|
+
/** True when a string is a YYYY-MM-DD calendar date. */
|
|
15
|
+
export function isIsoDate(value) {
|
|
16
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
17
|
+
}
|
|
18
|
+
/** Enumerate dates from start (inclusive) to end (exclusive). */
|
|
19
|
+
export function datesInRange(startInclusive, endExclusive) {
|
|
20
|
+
const out = [];
|
|
21
|
+
let cur = startInclusive;
|
|
22
|
+
while (cur < endExclusive) {
|
|
23
|
+
out.push(cur);
|
|
24
|
+
cur = addDay(cur);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
29
|
+
/** The weekday name for a YYYY-MM-DD date. */
|
|
30
|
+
export function weekdayOf(date) {
|
|
31
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
32
|
+
return WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Some ICU builds render midnight on a 24-hour clock as "24"; normalise it to
|
|
36
|
+
* "00" so a wall-clock hour is always in [00, 23]. Defensive against ICU
|
|
37
|
+
* variation across Node runtimes — inert where the clock is already h23.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeHour(hour) {
|
|
40
|
+
return hour === "24" ? "00" : hour;
|
|
41
|
+
}
|
|
42
|
+
/** Resolve an RFC3339 timed value to wall-clock date + HH:MM in the target zone. */
|
|
43
|
+
export function resolveWallClock(dateTime, timeZone) {
|
|
44
|
+
const dt = new Date(dateTime);
|
|
45
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
46
|
+
timeZone,
|
|
47
|
+
year: "numeric",
|
|
48
|
+
month: "2-digit",
|
|
49
|
+
day: "2-digit",
|
|
50
|
+
hour: "2-digit",
|
|
51
|
+
minute: "2-digit",
|
|
52
|
+
hour12: false,
|
|
53
|
+
}).formatToParts(dt);
|
|
54
|
+
const get = (type) => parts.find((p) => p.type === type).value;
|
|
55
|
+
const hour = normalizeHour(get("hour"));
|
|
56
|
+
return { date: `${get("year")}-${get("month")}-${get("day")}`, time: `${hour}:${get("minute")}` };
|
|
57
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { isCancelled, isFree, isNonBlockingType, isDeclined, isBusy, eventTitle, buildAvailability, } from "./parse.js";
|
|
2
|
+
export { isAllDay, allDayDate, addDay, isIsoDate, datesInRange, weekdayOf, resolveWallClock, } from "./dates.js";
|
|
3
|
+
export type { RawEvent, RawEventTime, RawAttendee, CalendarEventsResponse, CalendarInput, AvailabilityInput, BusyBlock, AllDayEvent, DayAvailability, AvailabilityOutput, } from "./types.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RawEvent, AvailabilityInput, AvailabilityOutput } from "./types.js";
|
|
2
|
+
/** Google marks deleted/cancelled instances with status "cancelled". */
|
|
3
|
+
export declare function isCancelled(e: RawEvent): boolean;
|
|
4
|
+
/** The athlete set themselves free during this event, so it does not block training. */
|
|
5
|
+
export declare function isFree(e: RawEvent): boolean;
|
|
6
|
+
/** Event types that never represent a real time commitment. */
|
|
7
|
+
export declare function isNonBlockingType(e: RawEvent): boolean;
|
|
8
|
+
/** The athlete declined this invitation. */
|
|
9
|
+
export declare function isDeclined(e: RawEvent): boolean;
|
|
10
|
+
/** Whether this event should count against the athlete's availability. */
|
|
11
|
+
export declare function isBusy(e: RawEvent): boolean;
|
|
12
|
+
/** A display title, with a placeholder for untitled events. */
|
|
13
|
+
export declare function eventTitle(e: RawEvent): string;
|
|
14
|
+
/**
|
|
15
|
+
* Normalise verbatim `list_events` responses into a clean per-day picture of
|
|
16
|
+
* busy blocks and all-day events. Every date in the window appears, so a clear
|
|
17
|
+
* day reads as clear rather than missing. All-day `end` dates are exclusive.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildAvailability(input: AvailabilityInput): AvailabilityOutput;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { isAllDay, allDayDate, addDay, datesInRange, weekdayOf, resolveWallClock, } from "./dates.js";
|
|
2
|
+
/** Google marks deleted/cancelled instances with status "cancelled". */
|
|
3
|
+
export function isCancelled(e) {
|
|
4
|
+
return e.status === "cancelled";
|
|
5
|
+
}
|
|
6
|
+
/** The athlete set themselves free during this event, so it does not block training. */
|
|
7
|
+
export function isFree(e) {
|
|
8
|
+
return e.transparency === "transparent" || e.availability === "AVAILABILITY_FREE";
|
|
9
|
+
}
|
|
10
|
+
const NON_BLOCKING_TYPES = new Set(["BIRTHDAY", "WORKING_LOCATION"]);
|
|
11
|
+
/** Event types that never represent a real time commitment. */
|
|
12
|
+
export function isNonBlockingType(e) {
|
|
13
|
+
return e.eventType !== undefined && NON_BLOCKING_TYPES.has(e.eventType);
|
|
14
|
+
}
|
|
15
|
+
/** The athlete declined this invitation. */
|
|
16
|
+
export function isDeclined(e) {
|
|
17
|
+
const self = e.attendees?.find((a) => a.self);
|
|
18
|
+
return self?.responseStatus === "declined";
|
|
19
|
+
}
|
|
20
|
+
/** Whether this event should count against the athlete's availability. */
|
|
21
|
+
export function isBusy(e) {
|
|
22
|
+
return !isCancelled(e) && !isFree(e) && !isNonBlockingType(e) && !isDeclined(e);
|
|
23
|
+
}
|
|
24
|
+
/** A display title, with a placeholder for untitled events. */
|
|
25
|
+
export function eventTitle(e) {
|
|
26
|
+
const title = (e.summary ?? "").trim();
|
|
27
|
+
return title.length > 0 ? title : "(no title)";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Normalise verbatim `list_events` responses into a clean per-day picture of
|
|
31
|
+
* busy blocks and all-day events. Every date in the window appears, so a clear
|
|
32
|
+
* day reads as clear rather than missing. All-day `end` dates are exclusive.
|
|
33
|
+
*/
|
|
34
|
+
export function buildAvailability(input) {
|
|
35
|
+
const { timeZone, window } = input;
|
|
36
|
+
const days = new Map();
|
|
37
|
+
for (const date of datesInRange(window.start, window.end)) {
|
|
38
|
+
days.set(date, { date, weekday: weekdayOf(date), allDayEvents: [], busyBlocks: [] });
|
|
39
|
+
}
|
|
40
|
+
for (const cal of input.calendars) {
|
|
41
|
+
const events = cal.responses.flatMap((r) => r.events ?? []);
|
|
42
|
+
for (const event of events) {
|
|
43
|
+
if (!isBusy(event))
|
|
44
|
+
continue;
|
|
45
|
+
const title = eventTitle(event);
|
|
46
|
+
if (isAllDay(event)) {
|
|
47
|
+
const startDate = allDayDate(event.start.date);
|
|
48
|
+
const endExclusive = event.end?.date ? allDayDate(event.end.date) : addDay(startDate);
|
|
49
|
+
for (const date of datesInRange(startDate, endExclusive)) {
|
|
50
|
+
days.get(date)?.allDayEvents.push({ title, calendar: cal.summary });
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (event.start?.dateTime && event.end?.dateTime) {
|
|
55
|
+
const start = resolveWallClock(event.start.dateTime, timeZone);
|
|
56
|
+
const end = resolveWallClock(event.end.dateTime, timeZone);
|
|
57
|
+
// A timed block is attributed to its start day; multi-day timed events are vanishingly rare.
|
|
58
|
+
days.get(start.date)?.busyBlocks.push({
|
|
59
|
+
start: start.time,
|
|
60
|
+
end: end.time,
|
|
61
|
+
title,
|
|
62
|
+
calendar: cal.summary,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const day of days.values()) {
|
|
68
|
+
day.busyBlocks.sort((a, b) => a.start.localeCompare(b.start) || a.end.localeCompare(b.end));
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
window: { start: window.start, end: window.end, timeZone },
|
|
72
|
+
days: [...days.values()],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** One time endpoint of a calendar event: all-day (`date`) or timed (`dateTime`). */
|
|
2
|
+
export interface RawEventTime {
|
|
3
|
+
/** All-day marker, e.g. "2026-07-25T00:00:00Z" or "2026-07-25". */
|
|
4
|
+
date?: string;
|
|
5
|
+
/** Timed RFC3339 value with offset, e.g. "2026-08-04T18:00:00+02:00". */
|
|
6
|
+
dateTime?: string;
|
|
7
|
+
timeZone?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface RawAttendee {
|
|
10
|
+
email?: string;
|
|
11
|
+
self?: boolean;
|
|
12
|
+
responseStatus?: string;
|
|
13
|
+
}
|
|
14
|
+
/** A single event from a `list_events` response. All fields optional. */
|
|
15
|
+
export interface RawEvent {
|
|
16
|
+
summary?: string;
|
|
17
|
+
status?: string;
|
|
18
|
+
transparency?: string;
|
|
19
|
+
availability?: string;
|
|
20
|
+
eventType?: string;
|
|
21
|
+
start?: RawEventTime;
|
|
22
|
+
end?: RawEventTime;
|
|
23
|
+
attendees?: RawAttendee[];
|
|
24
|
+
}
|
|
25
|
+
/** One verbatim `list_events` page response. */
|
|
26
|
+
export interface CalendarEventsResponse {
|
|
27
|
+
events?: RawEvent[];
|
|
28
|
+
summary?: string;
|
|
29
|
+
timeZone?: string;
|
|
30
|
+
nextPageToken?: string;
|
|
31
|
+
}
|
|
32
|
+
/** One selected calendar's verbatim page responses. */
|
|
33
|
+
export interface CalendarInput {
|
|
34
|
+
summary: string;
|
|
35
|
+
responses: CalendarEventsResponse[];
|
|
36
|
+
}
|
|
37
|
+
/** The wrapper file the skill assembles and passes to the CLI. */
|
|
38
|
+
export interface AvailabilityInput {
|
|
39
|
+
timeZone: string;
|
|
40
|
+
/** Window covered; `end` is exclusive, both YYYY-MM-DD. */
|
|
41
|
+
window: {
|
|
42
|
+
start: string;
|
|
43
|
+
end: string;
|
|
44
|
+
};
|
|
45
|
+
calendars: CalendarInput[];
|
|
46
|
+
}
|
|
47
|
+
export interface BusyBlock {
|
|
48
|
+
start: string;
|
|
49
|
+
end: string;
|
|
50
|
+
title: string;
|
|
51
|
+
calendar: string;
|
|
52
|
+
}
|
|
53
|
+
export interface AllDayEvent {
|
|
54
|
+
title: string;
|
|
55
|
+
calendar: string;
|
|
56
|
+
}
|
|
57
|
+
export interface DayAvailability {
|
|
58
|
+
date: string;
|
|
59
|
+
weekday: string;
|
|
60
|
+
allDayEvents: AllDayEvent[];
|
|
61
|
+
busyBlocks: BusyBlock[];
|
|
62
|
+
}
|
|
63
|
+
export interface AvailabilityOutput {
|
|
64
|
+
window: {
|
|
65
|
+
start: string;
|
|
66
|
+
end: string;
|
|
67
|
+
timeZone: string;
|
|
68
|
+
};
|
|
69
|
+
days: DayAvailability[];
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,8 @@ 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";
|
|
10
12
|
import { readFileSync, writeFileSync } from "fs";
|
|
11
13
|
import { dirname, join } from "path";
|
|
12
14
|
import { fileURLToPath } from "url";
|
|
@@ -96,6 +98,42 @@ function parseArgs() {
|
|
|
96
98
|
}
|
|
97
99
|
return exportArgs;
|
|
98
100
|
}
|
|
101
|
+
if (args[0] === "garmin-status") {
|
|
102
|
+
const statusArgs = {
|
|
103
|
+
command: "garmin-status",
|
|
104
|
+
days: 14,
|
|
105
|
+
json: args.includes("--json"),
|
|
106
|
+
};
|
|
107
|
+
for (const arg of args) {
|
|
108
|
+
if (arg.startsWith("--days=")) {
|
|
109
|
+
const parsed = parseInt(arg.slice("--days=".length), 10);
|
|
110
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
111
|
+
log.error("--days must be a positive integer");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
statusArgs.days = parsed;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return statusArgs;
|
|
118
|
+
}
|
|
119
|
+
if (args[0] === "import-garmin") {
|
|
120
|
+
if (!args[1]) {
|
|
121
|
+
log.error("import-garmin command requires an input JSON file");
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
return { command: "import-garmin", inputFile: args[1] };
|
|
125
|
+
}
|
|
126
|
+
if (args[0] === "calendar-availability") {
|
|
127
|
+
if (!args[1]) {
|
|
128
|
+
log.error("calendar-availability command requires an input JSON file");
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
command: "calendar-availability",
|
|
133
|
+
inputFile: args[1],
|
|
134
|
+
json: args.includes("--json"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
99
137
|
if (args[0] === "query") {
|
|
100
138
|
if (!args[1]) {
|
|
101
139
|
log.error("query command requires a SQL statement");
|
|
@@ -140,6 +178,9 @@ Commands:
|
|
|
140
178
|
auth Get Strava authorization URL or exchange code for tokens
|
|
141
179
|
render <file> Render a training plan JSON to HTML
|
|
142
180
|
export-garmin <file> Convert a plan JSON to Garmin sync workouts
|
|
181
|
+
garmin-status Show which dates have Garmin recovery data, and what to fetch
|
|
182
|
+
import-garmin <file> Import Garmin recovery MCP responses into the database
|
|
183
|
+
calendar-availability <file> Normalize calendar MCP events into per-day busy blocks
|
|
143
184
|
query <sql> Run a SQL query against the database
|
|
144
185
|
help Show this help message
|
|
145
186
|
|
|
@@ -167,6 +208,13 @@ Export Garmin Options:
|
|
|
167
208
|
--workout=ID Export only the workout with this id
|
|
168
209
|
--date=YYYY-MM-DD Export only workouts on this date
|
|
169
210
|
|
|
211
|
+
Garmin Status Options:
|
|
212
|
+
--days=N Days of history to check (default: 14)
|
|
213
|
+
--json Output as JSON, including the computed fetch list
|
|
214
|
+
|
|
215
|
+
Calendar Availability Options:
|
|
216
|
+
--json Output as JSON (default: plain per-day text)
|
|
217
|
+
|
|
170
218
|
Query Options:
|
|
171
219
|
--json Output as JSON (default: plain text)
|
|
172
220
|
|
|
@@ -192,6 +240,18 @@ Examples:
|
|
|
192
240
|
|
|
193
241
|
# Query the database
|
|
194
242
|
npx @robinthues/rt-claude-coach query "SELECT * FROM weekly_volume LIMIT 5"
|
|
243
|
+
|
|
244
|
+
# Which recovery dates are missing, and what should be fetched?
|
|
245
|
+
npx @robinthues/rt-claude-coach garmin-status --days=14 --json
|
|
246
|
+
|
|
247
|
+
# Import verbatim Garmin MCP responses
|
|
248
|
+
npx @robinthues/rt-claude-coach import-garmin garmin-recovery.json
|
|
249
|
+
|
|
250
|
+
# Normalize verbatim calendar MCP events into per-day availability
|
|
251
|
+
npx @robinthues/rt-claude-coach calendar-availability calendar-events.json --json
|
|
252
|
+
|
|
253
|
+
# Read recovery data back
|
|
254
|
+
npx @robinthues/rt-claude-coach query "SELECT * FROM recovery_recent LIMIT 14"
|
|
195
255
|
`);
|
|
196
256
|
}
|
|
197
257
|
// ============================================================================
|
|
@@ -485,6 +545,125 @@ function runExportGarmin(args) {
|
|
|
485
545
|
log.success(`Wrote ${entries.length} Garmin workout entries to: ${outputFile}`);
|
|
486
546
|
}
|
|
487
547
|
// ============================================================================
|
|
548
|
+
// Garmin Recovery Commands
|
|
549
|
+
// ============================================================================
|
|
550
|
+
async function runGarminStatus(args) {
|
|
551
|
+
await initDatabase();
|
|
552
|
+
migrate();
|
|
553
|
+
const dates = lastNDates(args.days);
|
|
554
|
+
const statuses = getDateStatuses(dates);
|
|
555
|
+
const today = dates[dates.length - 1];
|
|
556
|
+
const yesterday = lastNDates(2)[0];
|
|
557
|
+
const fetch = buildFetchList(statuses, today, yesterday);
|
|
558
|
+
if (args.json) {
|
|
559
|
+
console.log(JSON.stringify({ dates: statuses, fetch }, null, 2));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
for (const { date, status } of statuses) {
|
|
563
|
+
console.log(`${date} ${status}`);
|
|
564
|
+
}
|
|
565
|
+
console.log(`\nFetch these ${fetch.length} date(s): ${fetch.join(", ")}`);
|
|
566
|
+
}
|
|
567
|
+
async function runImportGarmin(args) {
|
|
568
|
+
let fileContents;
|
|
569
|
+
try {
|
|
570
|
+
fileContents = readFileSync(args.inputFile, "utf-8");
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
let file;
|
|
577
|
+
try {
|
|
578
|
+
file = JSON.parse(fileContents);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
log.error("Input file is not valid JSON");
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
if (typeof file !== "object" || file === null || !Array.isArray(file.days)) {
|
|
585
|
+
log.error('Input file must be an object with a "days" array');
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
await initDatabase();
|
|
589
|
+
migrate();
|
|
590
|
+
const failures = [];
|
|
591
|
+
let imported = 0;
|
|
592
|
+
for (const day of file.days) {
|
|
593
|
+
try {
|
|
594
|
+
upsertRecoveryDay(parseRecoveryDay(day));
|
|
595
|
+
console.log(` ${day.date ?? "(no date)"} imported`);
|
|
596
|
+
imported++;
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
const date = day.date ?? "(no date)";
|
|
600
|
+
const reason = err.message;
|
|
601
|
+
console.log(` ${date} FAILED — ${reason}`);
|
|
602
|
+
failures.push({ date, reason });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (failures.length === 0) {
|
|
606
|
+
log.success(`Imported ${imported} of ${file.days.length} date(s)`);
|
|
607
|
+
}
|
|
608
|
+
else if (imported > 0) {
|
|
609
|
+
log.warn(`Imported ${imported} of ${file.days.length} date(s)`);
|
|
610
|
+
}
|
|
611
|
+
if (failures.length > 0) {
|
|
612
|
+
log.error(`${failures.length} date(s) failed; successfully parsed dates were still committed`);
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// ============================================================================
|
|
617
|
+
// Calendar Availability Command
|
|
618
|
+
// ============================================================================
|
|
619
|
+
function runCalendarAvailability(args) {
|
|
620
|
+
let fileContents;
|
|
621
|
+
try {
|
|
622
|
+
fileContents = readFileSync(args.inputFile, "utf-8");
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
log.error(`Could not read input file: ${args.inputFile}`);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
let input;
|
|
629
|
+
try {
|
|
630
|
+
input = JSON.parse(fileContents);
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
log.error("Input file is not valid JSON");
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
if (typeof input !== "object" ||
|
|
637
|
+
input === null ||
|
|
638
|
+
typeof input.timeZone !== "string" ||
|
|
639
|
+
typeof input.window !== "object" ||
|
|
640
|
+
input.window === null ||
|
|
641
|
+
typeof input.window.start !== "string" ||
|
|
642
|
+
typeof input.window.end !== "string" ||
|
|
643
|
+
!Array.isArray(input.calendars)) {
|
|
644
|
+
log.error('Input file must have "timeZone", "window" {start,end}, and a "calendars" array');
|
|
645
|
+
process.exit(1);
|
|
646
|
+
}
|
|
647
|
+
if (!isIsoDate(input.window.start) || !isIsoDate(input.window.end)) {
|
|
648
|
+
log.error('Input file "window.start" and "window.end" must be YYYY-MM-DD dates');
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
const output = buildAvailability(input);
|
|
652
|
+
if (args.json) {
|
|
653
|
+
console.log(JSON.stringify(output, null, 2));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
for (const day of output.days) {
|
|
657
|
+
const parts = [];
|
|
658
|
+
for (const e of day.allDayEvents)
|
|
659
|
+
parts.push(`[all-day] ${e.title}`);
|
|
660
|
+
for (const b of day.busyBlocks)
|
|
661
|
+
parts.push(`${b.start}-${b.end} ${b.title}`);
|
|
662
|
+
const label = parts.length > 0 ? parts.join("; ") : "clear";
|
|
663
|
+
console.log(`${day.date} ${day.weekday.padEnd(9)} ${label}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// ============================================================================
|
|
488
667
|
// Query Command
|
|
489
668
|
// ============================================================================
|
|
490
669
|
async function runQuery(args) {
|
|
@@ -519,6 +698,15 @@ async function main() {
|
|
|
519
698
|
case "export-garmin":
|
|
520
699
|
runExportGarmin(args);
|
|
521
700
|
break;
|
|
701
|
+
case "garmin-status":
|
|
702
|
+
await runGarminStatus(args);
|
|
703
|
+
break;
|
|
704
|
+
case "import-garmin":
|
|
705
|
+
await runImportGarmin(args);
|
|
706
|
+
break;
|
|
707
|
+
case "calendar-availability":
|
|
708
|
+
runCalendarAvailability(args);
|
|
709
|
+
break;
|
|
522
710
|
case "query":
|
|
523
711
|
await runQuery(args);
|
|
524
712
|
break;
|
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
|
+
}
|
|
@@ -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;
|