@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.
@@ -0,0 +1,128 @@
1
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
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 function unwrapPayload(payload) {
8
+ if (payload == null)
9
+ return null;
10
+ if (typeof payload === "string") {
11
+ return parseJson(payload);
12
+ }
13
+ if (typeof payload === "object" && "result" in payload) {
14
+ return unwrapPayload(payload.result);
15
+ }
16
+ return payload;
17
+ }
18
+ function parseJson(text) {
19
+ try {
20
+ return JSON.parse(text);
21
+ }
22
+ catch {
23
+ throw new Error("payload could not be parsed as JSON");
24
+ }
25
+ }
26
+ /**
27
+ * Readiness returns several entries per day with different contexts and
28
+ * different scores. Prefer the morning reading: it answers "should I train
29
+ * hard today". The post-exercise reset reflects the session just completed and
30
+ * would double-count fatigue already visible in that day's Strava activity.
31
+ */
32
+ export function selectReadinessEntry(payload) {
33
+ if (payload == null)
34
+ return null;
35
+ const entries = Array.isArray(payload)
36
+ ? payload
37
+ : [payload];
38
+ if (entries.length === 0)
39
+ return null;
40
+ const wakeup = entries.find((entry) => entry.context === "AFTER_WAKEUP_RESET");
41
+ if (wakeup)
42
+ return wakeup;
43
+ return [...entries].sort((a, b) => String(a.timestamp ?? "").localeCompare(String(b.timestamp ?? "")))[0];
44
+ }
45
+ function num(value) {
46
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
47
+ }
48
+ function int(value) {
49
+ const parsed = num(value);
50
+ return parsed === null ? null : Math.round(parsed);
51
+ }
52
+ function str(value) {
53
+ return typeof value === "string" ? value : null;
54
+ }
55
+ /** `allMetrics.metricsMap.WELLNESS_RESTING_HEART_RATE[0].value`, defensively. */
56
+ function extractRestingHr(payload) {
57
+ const metricsMap = payload
58
+ ?.allMetrics?.metricsMap;
59
+ const series = metricsMap?.WELLNESS_RESTING_HEART_RATE;
60
+ if (!Array.isArray(series) || series.length === 0)
61
+ return null;
62
+ return int(series[0]?.value);
63
+ }
64
+ function unwrapGroup(input, group) {
65
+ try {
66
+ return unwrapPayload(input[group]);
67
+ }
68
+ catch (err) {
69
+ throw new Error(`${group}: ${err.message}`);
70
+ }
71
+ }
72
+ /**
73
+ * Map one day of verbatim MCP payloads onto a flat row. All field mapping
74
+ * lives here so a Garmin field rename fails a test instead of being silently
75
+ * absorbed. Throws on an invalid date or unparseable payload; never on absent
76
+ * data.
77
+ */
78
+ export function parseRecoveryDay(input) {
79
+ if (!DATE_PATTERN.test(input.date ?? "")) {
80
+ throw new Error(`invalid date: ${input.date}`);
81
+ }
82
+ const readinessRaw = unwrapGroup(input, "readiness");
83
+ const hrv = unwrapGroup(input, "hrv");
84
+ const sleep = unwrapGroup(input, "sleep");
85
+ const rhr = unwrapGroup(input, "rhr");
86
+ const readiness = selectReadinessEntry(readinessRaw);
87
+ const raw = {};
88
+ if (readinessRaw != null)
89
+ raw.readiness = readinessRaw;
90
+ if (hrv != null)
91
+ raw.hrv = hrv;
92
+ if (sleep != null)
93
+ raw.sleep = sleep;
94
+ if (rhr != null)
95
+ raw.rhr = rhr;
96
+ return {
97
+ date: input.date,
98
+ readiness_score: int(readiness?.score),
99
+ readiness_level: str(readiness?.level),
100
+ readiness_feedback: str(readiness?.feedback),
101
+ readiness_context: str(readiness?.context),
102
+ readiness_timestamp: str(readiness?.timestamp),
103
+ recovery_time_hours: num(readiness?.recovery_time_hours),
104
+ acute_load: int(readiness?.acute_load),
105
+ sleep_factor_percent: int(readiness?.sleep_factor_percent),
106
+ recovery_factor_percent: int(readiness?.recovery_factor_percent),
107
+ training_load_factor_percent: int(readiness?.training_load_factor_percent),
108
+ hrv_factor_percent: int(readiness?.hrv_factor_percent),
109
+ stress_history_factor_percent: int(readiness?.stress_history_factor_percent),
110
+ sleep_history_factor_percent: int(readiness?.sleep_history_factor_percent),
111
+ hrv_last_night_ms: int(hrv?.last_night_avg_hrv_ms),
112
+ hrv_weekly_avg_ms: int(hrv?.weekly_avg_hrv_ms),
113
+ hrv_status: str(hrv?.status),
114
+ hrv_baseline_low_ms: int(hrv?.baseline_balanced_low_ms),
115
+ hrv_baseline_upper_ms: int(hrv?.baseline_balanced_upper_ms),
116
+ sleep_seconds: int(sleep?.sleep_seconds),
117
+ deep_sleep_seconds: int(sleep?.deep_sleep_seconds),
118
+ light_sleep_seconds: int(sleep?.light_sleep_seconds),
119
+ rem_sleep_seconds: int(sleep?.rem_sleep_seconds),
120
+ awake_seconds: int(sleep?.awake_seconds),
121
+ sleep_score: int(sleep?.sleep_score),
122
+ sleep_score_qualifier: str(sleep?.sleep_score_qualifier),
123
+ avg_overnight_hrv: num(sleep?.avg_overnight_hrv),
124
+ avg_sleep_stress: num(sleep?.avg_sleep_stress),
125
+ resting_hr: extractRestingHr(rhr),
126
+ raw_json: JSON.stringify(raw),
127
+ };
128
+ }
@@ -0,0 +1,14 @@
1
+ import type { DateStatusRow, RecoveryDay } from "./types.js";
2
+ /**
3
+ * Upsert one day. Every column coalesces to its existing value, so a partial
4
+ * re-sync (only sleep succeeded, say) never blanks columns written earlier.
5
+ * `raw_json` is merged group-by-group in TypeScript, because SQL coalescing
6
+ * would replace the whole blob and drop earlier groups.
7
+ */
8
+ export declare function upsertRecoveryDay(day: RecoveryDay): void;
9
+ /**
10
+ * Classify each requested date: `missing` (no row), `partial` (a row exists but
11
+ * at least one payload group is entirely NULL), or `present`. Order matches the
12
+ * requested order.
13
+ */
14
+ export declare function getDateStatuses(dates: string[]): DateStatusRow[];
@@ -0,0 +1,108 @@
1
+ import { execute, queryJson } from "../db/client.js";
2
+ import { escapeString, numOrNull } from "../db/sql.js";
3
+ /**
4
+ * Columns written by an upsert, excluding `date`, `raw_json` and `synced_at`.
5
+ * These two lists must stay in sync with `garmin_daily` in `schema.sql`.
6
+ */
7
+ const TEXT_COLUMNS = [
8
+ "readiness_level",
9
+ "readiness_feedback",
10
+ "readiness_context",
11
+ "readiness_timestamp",
12
+ "hrv_status",
13
+ "sleep_score_qualifier",
14
+ ];
15
+ const NUMERIC_COLUMNS = [
16
+ "readiness_score",
17
+ "recovery_time_hours",
18
+ "acute_load",
19
+ "sleep_factor_percent",
20
+ "recovery_factor_percent",
21
+ "training_load_factor_percent",
22
+ "hrv_factor_percent",
23
+ "stress_history_factor_percent",
24
+ "sleep_history_factor_percent",
25
+ "hrv_last_night_ms",
26
+ "hrv_weekly_avg_ms",
27
+ "hrv_baseline_low_ms",
28
+ "hrv_baseline_upper_ms",
29
+ "sleep_seconds",
30
+ "deep_sleep_seconds",
31
+ "light_sleep_seconds",
32
+ "rem_sleep_seconds",
33
+ "awake_seconds",
34
+ "sleep_score",
35
+ "avg_overnight_hrv",
36
+ "avg_sleep_stress",
37
+ "resting_hr",
38
+ ];
39
+ /** One representative column per payload group, for status classification. */
40
+ const GROUP_MARKERS = {
41
+ readiness: "readiness_score",
42
+ hrv: "hrv_last_night_ms",
43
+ sleep: "sleep_seconds",
44
+ rhr: "resting_hr",
45
+ };
46
+ function mergeRawJson(date, incoming) {
47
+ const existing = queryJson(`SELECT raw_json FROM garmin_daily WHERE date = ${escapeString(date)};`);
48
+ if (existing.length === 0 || !existing[0].raw_json)
49
+ return incoming;
50
+ let previous;
51
+ try {
52
+ previous = JSON.parse(existing[0].raw_json);
53
+ }
54
+ catch {
55
+ previous = {};
56
+ }
57
+ const next = JSON.parse(incoming);
58
+ return JSON.stringify({ ...previous, ...next });
59
+ }
60
+ /**
61
+ * Upsert one day. Every column coalesces to its existing value, so a partial
62
+ * re-sync (only sleep succeeded, say) never blanks columns written earlier.
63
+ * `raw_json` is merged group-by-group in TypeScript, because SQL coalescing
64
+ * would replace the whole blob and drop earlier groups.
65
+ */
66
+ export function upsertRecoveryDay(day) {
67
+ const columns = [...TEXT_COLUMNS, ...NUMERIC_COLUMNS];
68
+ const values = [
69
+ ...TEXT_COLUMNS.map((col) => escapeString(day[col])),
70
+ ...NUMERIC_COLUMNS.map((col) => numOrNull(day[col])),
71
+ ];
72
+ const updates = columns
73
+ .map((col) => `${col} = COALESCE(excluded.${col}, garmin_daily.${col})`)
74
+ .join(",\n ");
75
+ execute(`
76
+ INSERT INTO garmin_daily (date, ${columns.join(", ")}, raw_json, synced_at)
77
+ VALUES (
78
+ ${escapeString(day.date)},
79
+ ${values.join(", ")},
80
+ ${escapeString(mergeRawJson(day.date, day.raw_json))},
81
+ datetime('now')
82
+ )
83
+ ON CONFLICT(date) DO UPDATE SET
84
+ ${updates},
85
+ raw_json = excluded.raw_json,
86
+ synced_at = excluded.synced_at;
87
+ `);
88
+ }
89
+ /**
90
+ * Classify each requested date: `missing` (no row), `partial` (a row exists but
91
+ * at least one payload group is entirely NULL), or `present`. Order matches the
92
+ * requested order.
93
+ */
94
+ export function getDateStatuses(dates) {
95
+ if (dates.length === 0)
96
+ return [];
97
+ const markers = Object.values(GROUP_MARKERS);
98
+ const list = dates.map((date) => escapeString(date)).join(", ");
99
+ const rows = queryJson(`SELECT date, ${markers.join(", ")} FROM garmin_daily WHERE date IN (${list});`);
100
+ const byDate = new Map(rows.map((row) => [String(row.date), row]));
101
+ return dates.map((date) => {
102
+ const row = byDate.get(date);
103
+ if (!row)
104
+ return { date, status: "missing" };
105
+ const complete = markers.every((marker) => row[marker] !== null);
106
+ return { date, status: complete ? "present" : "partial" };
107
+ });
108
+ }
@@ -0,0 +1,50 @@
1
+ /** One row of `garmin_daily`. Every metric is nullable: absence is normal. */
2
+ export interface RecoveryDay {
3
+ date: string;
4
+ readiness_score: number | null;
5
+ readiness_level: string | null;
6
+ readiness_feedback: string | null;
7
+ readiness_context: string | null;
8
+ readiness_timestamp: string | null;
9
+ recovery_time_hours: number | null;
10
+ acute_load: number | null;
11
+ sleep_factor_percent: number | null;
12
+ recovery_factor_percent: number | null;
13
+ training_load_factor_percent: number | null;
14
+ hrv_factor_percent: number | null;
15
+ stress_history_factor_percent: number | null;
16
+ sleep_history_factor_percent: number | null;
17
+ hrv_last_night_ms: number | null;
18
+ hrv_weekly_avg_ms: number | null;
19
+ hrv_status: string | null;
20
+ hrv_baseline_low_ms: number | null;
21
+ hrv_baseline_upper_ms: number | null;
22
+ sleep_seconds: number | null;
23
+ deep_sleep_seconds: number | null;
24
+ light_sleep_seconds: number | null;
25
+ rem_sleep_seconds: number | null;
26
+ awake_seconds: number | null;
27
+ sleep_score: number | null;
28
+ sleep_score_qualifier: string | null;
29
+ avg_overnight_hrv: number | null;
30
+ avg_sleep_stress: number | null;
31
+ resting_hr: number | null;
32
+ /** JSON object keyed by group name, holding the unwrapped MCP payloads. */
33
+ raw_json: string;
34
+ }
35
+ /** One day of verbatim MCP responses, as written by the skill's sync step. */
36
+ export interface RecoveryDayInput {
37
+ date: string;
38
+ readiness?: unknown;
39
+ hrv?: unknown;
40
+ sleep?: unknown;
41
+ rhr?: unknown;
42
+ }
43
+ export interface RecoveryImportFile {
44
+ days: RecoveryDayInput[];
45
+ }
46
+ export type DateStatus = "present" | "partial" | "missing";
47
+ export interface DateStatusRow {
48
+ date: string;
49
+ status: DateStatus;
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,6 @@
1
+ import { escapeString } from "../db/sql.js";
1
2
  import type { StravaActivity, StravaDetailedActivity } from "./types.js";
2
- export declare function escapeString(str: string | null | undefined): string;
3
+ export { escapeString };
3
4
  export declare function upsertSummaryActivity(activity: StravaActivity): void;
4
5
  export declare function insertAthlete(athlete: {
5
6
  id: number;
@@ -1,9 +1,6 @@
1
1
  import { execute, queryJson } from "../db/client.js";
2
- export function escapeString(str) {
3
- if (str == null)
4
- return "NULL";
5
- return `'${str.replace(/'/g, "''")}'`;
6
- }
2
+ import { escapeString } from "../db/sql.js";
3
+ export { escapeString };
7
4
  // Summary-only upsert. On conflict, detail columns (description, private_note,
8
5
  // calories, raw_json, details_synced_at) are deliberately not updated: the
9
6
  // summary endpoint doesn't return them, and REPLACE would wipe fetched details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robinthues/rt-claude-coach",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "author": "Robin Thues",
5
5
  "license": "MIT",
6
6
  "repository": {