@robinthues/rt-claude-coach 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +98 -0
- package/bin/claude-coach.js +10 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +530 -0
- package/dist/db/client.d.ts +8 -0
- package/dist/db/client.js +111 -0
- package/dist/db/migrate.d.ts +2 -0
- package/dist/db/migrate.js +30 -0
- package/dist/db/schema.sql +108 -0
- package/dist/garmin/context.d.ts +31 -0
- package/dist/garmin/context.js +25 -0
- package/dist/garmin/convert.d.ts +28 -0
- package/dist/garmin/convert.js +117 -0
- package/dist/garmin/dto.d.ts +132 -0
- package/dist/garmin/dto.js +35 -0
- package/dist/garmin/index.d.ts +3 -0
- package/dist/garmin/index.js +1 -0
- package/dist/garmin/pace.d.ts +6 -0
- package/dist/garmin/pace.js +23 -0
- package/dist/garmin/steps.d.ts +12 -0
- package/dist/garmin/steps.js +147 -0
- package/dist/garmin/targets.d.ts +15 -0
- package/dist/garmin/targets.js +85 -0
- package/dist/lib/config.d.ts +27 -0
- package/dist/lib/config.js +86 -0
- package/dist/lib/logging.d.ts +13 -0
- package/dist/lib/logging.js +28 -0
- package/dist/schema/training-plan.d.ts +288 -0
- package/dist/schema/training-plan.js +88 -0
- package/dist/strava/api.d.ts +23 -0
- package/dist/strava/api.js +88 -0
- package/dist/strava/details.d.ts +11 -0
- package/dist/strava/details.js +50 -0
- package/dist/strava/oauth.d.ts +4 -0
- package/dist/strava/oauth.js +113 -0
- package/dist/strava/rate-limit.d.ts +9 -0
- package/dist/strava/rate-limit.js +16 -0
- package/dist/strava/store.d.ts +13 -0
- package/dist/strava/store.js +107 -0
- package/dist/strava/types.d.ts +49 -0
- package/dist/strava/types.js +1 -0
- package/dist/viewer/lib/export/erg.d.ts +26 -0
- package/dist/viewer/lib/export/erg.js +208 -0
- package/dist/viewer/lib/export/fit.d.ts +25 -0
- package/dist/viewer/lib/export/fit.js +308 -0
- package/dist/viewer/lib/export/ics.d.ts +13 -0
- package/dist/viewer/lib/export/ics.js +142 -0
- package/dist/viewer/lib/export/index.d.ts +50 -0
- package/dist/viewer/lib/export/index.js +229 -0
- package/dist/viewer/lib/export/zwo.d.ts +21 -0
- package/dist/viewer/lib/export/zwo.js +233 -0
- package/dist/viewer/lib/utils.d.ts +14 -0
- package/dist/viewer/lib/utils.js +125 -0
- package/dist/viewer/main.d.ts +5 -0
- package/dist/viewer/main.js +6 -0
- package/dist/viewer/stores/changes.d.ts +21 -0
- package/dist/viewer/stores/changes.js +49 -0
- package/dist/viewer/stores/plan.d.ts +4 -0
- package/dist/viewer/stores/plan.js +19 -0
- package/dist/viewer/stores/settings.d.ts +53 -0
- package/dist/viewer/stores/settings.js +215 -0
- package/package.json +67 -0
- package/templates/plan-viewer.html +70 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execSync, spawnSync } from "child_process";
|
|
2
|
+
import { getDbPath } from "../lib/config.js";
|
|
3
|
+
let cachedBackend = null;
|
|
4
|
+
/**
|
|
5
|
+
* Try to use Node's built-in SQLite module (Node 22.5+).
|
|
6
|
+
* Falls back to shelling out to sqlite3 CLI if not available.
|
|
7
|
+
*/
|
|
8
|
+
async function detectBackend() {
|
|
9
|
+
// Try Node.js built-in SQLite first (Node 22.5+)
|
|
10
|
+
try {
|
|
11
|
+
// Dynamic import to avoid syntax errors on older Node versions
|
|
12
|
+
const sqlite = await import("node:sqlite");
|
|
13
|
+
const dbPath = getDbPath();
|
|
14
|
+
const db = new sqlite.DatabaseSync(dbPath);
|
|
15
|
+
return {
|
|
16
|
+
query(sql) {
|
|
17
|
+
const stmt = db.prepare(sql);
|
|
18
|
+
const rows = stmt.all();
|
|
19
|
+
if (rows.length === 0)
|
|
20
|
+
return "";
|
|
21
|
+
// Format as simple text output (column values separated by |)
|
|
22
|
+
return rows
|
|
23
|
+
.map((row) => Object.values(row)
|
|
24
|
+
.map((v) => (v === null ? "" : String(v)))
|
|
25
|
+
.join("|"))
|
|
26
|
+
.join("\n");
|
|
27
|
+
},
|
|
28
|
+
queryJson(sql) {
|
|
29
|
+
const stmt = db.prepare(sql);
|
|
30
|
+
return stmt.all();
|
|
31
|
+
},
|
|
32
|
+
execute(sql) {
|
|
33
|
+
db.exec(sql);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Node.js built-in SQLite not available, try CLI
|
|
39
|
+
}
|
|
40
|
+
// Fallback: Use sqlite3 CLI
|
|
41
|
+
try {
|
|
42
|
+
// Check if sqlite3 is available
|
|
43
|
+
execSync("sqlite3 --version", { stdio: "ignore" });
|
|
44
|
+
return {
|
|
45
|
+
query(sql) {
|
|
46
|
+
const dbPath = getDbPath();
|
|
47
|
+
return execSync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, {
|
|
48
|
+
encoding: "utf-8",
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
queryJson(sql) {
|
|
52
|
+
const dbPath = getDbPath();
|
|
53
|
+
const result = execSync(`sqlite3 -json "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, {
|
|
54
|
+
encoding: "utf-8",
|
|
55
|
+
});
|
|
56
|
+
if (!result.trim())
|
|
57
|
+
return [];
|
|
58
|
+
return JSON.parse(result);
|
|
59
|
+
},
|
|
60
|
+
execute(sql) {
|
|
61
|
+
const dbPath = getDbPath();
|
|
62
|
+
const result = spawnSync("sqlite3", [dbPath], {
|
|
63
|
+
input: sql,
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
});
|
|
66
|
+
if (result.error)
|
|
67
|
+
throw result.error;
|
|
68
|
+
if (result.status !== 0) {
|
|
69
|
+
throw new Error(`SQLite error: ${result.stderr}`);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error("SQLite is not available. Please either:\n" +
|
|
76
|
+
" 1. Use Node.js 22.5+ (has built-in SQLite)\n" +
|
|
77
|
+
" 2. Install sqlite3 CLI (brew install sqlite3 / apt install sqlite3)");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Initialize the SQLite backend. Must be called before using other functions.
|
|
82
|
+
*/
|
|
83
|
+
export async function initDatabase() {
|
|
84
|
+
if (!cachedBackend) {
|
|
85
|
+
cachedBackend = await detectBackend();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Get the backend, throwing if not initialized.
|
|
90
|
+
*/
|
|
91
|
+
function getBackend() {
|
|
92
|
+
if (!cachedBackend) {
|
|
93
|
+
throw new Error("Database not initialized. Call initDatabase() first.");
|
|
94
|
+
}
|
|
95
|
+
return cachedBackend;
|
|
96
|
+
}
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Public API (synchronous after initialization)
|
|
99
|
+
// ============================================================================
|
|
100
|
+
export function query(sql) {
|
|
101
|
+
return getBackend().query(sql);
|
|
102
|
+
}
|
|
103
|
+
export function queryJson(sql) {
|
|
104
|
+
return getBackend().queryJson(sql);
|
|
105
|
+
}
|
|
106
|
+
export function execute(sql) {
|
|
107
|
+
getBackend().execute(sql);
|
|
108
|
+
}
|
|
109
|
+
export function runScript(script) {
|
|
110
|
+
execute(script);
|
|
111
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { execute, queryJson, runScript } from "./client.js";
|
|
5
|
+
import { ensureConfigDir } from "../lib/config.js";
|
|
6
|
+
import { log } from "../lib/logging.js";
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
// Columns added after the initial schema shipped. CREATE TABLE IF NOT EXISTS
|
|
9
|
+
// never alters existing tables, so these are applied via ALTER TABLE.
|
|
10
|
+
const COLUMN_MIGRATIONS = [
|
|
11
|
+
{ table: "activities", column: "private_note", type: "TEXT" },
|
|
12
|
+
{ table: "activities", column: "details_synced_at", type: "TEXT" },
|
|
13
|
+
{ table: "sync_log", column: "details_synced", type: "INTEGER" },
|
|
14
|
+
];
|
|
15
|
+
export function ensureColumns() {
|
|
16
|
+
for (const { table, column, type } of COLUMN_MIGRATIONS) {
|
|
17
|
+
const existing = queryJson(`SELECT name FROM pragma_table_info('${table}');`).map((row) => row.name);
|
|
18
|
+
if (!existing.includes(column)) {
|
|
19
|
+
execute(`ALTER TABLE ${table} ADD COLUMN ${column} ${type};`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function migrate() {
|
|
24
|
+
ensureConfigDir();
|
|
25
|
+
const schemaPath = join(__dirname, "schema.sql");
|
|
26
|
+
const schema = readFileSync(schemaPath, "utf-8");
|
|
27
|
+
runScript(schema);
|
|
28
|
+
ensureColumns();
|
|
29
|
+
log.success("Database schema initialized");
|
|
30
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
-- Core activity data
|
|
2
|
+
CREATE TABLE IF NOT EXISTS activities (
|
|
3
|
+
id INTEGER PRIMARY KEY, -- Strava activity ID
|
|
4
|
+
name TEXT,
|
|
5
|
+
sport_type TEXT, -- Run, Ride, Swim, etc.
|
|
6
|
+
start_date TEXT, -- ISO 8601 UTC
|
|
7
|
+
elapsed_time INTEGER, -- seconds
|
|
8
|
+
moving_time INTEGER, -- seconds
|
|
9
|
+
distance REAL, -- meters
|
|
10
|
+
total_elevation_gain REAL, -- meters
|
|
11
|
+
average_speed REAL, -- m/s
|
|
12
|
+
max_speed REAL, -- m/s
|
|
13
|
+
average_heartrate REAL,
|
|
14
|
+
max_heartrate REAL,
|
|
15
|
+
average_watts REAL, -- cycling/running power
|
|
16
|
+
max_watts REAL,
|
|
17
|
+
weighted_average_watts REAL, -- normalized power
|
|
18
|
+
kilojoules REAL,
|
|
19
|
+
suffer_score INTEGER, -- Strava's relative effort
|
|
20
|
+
average_cadence REAL,
|
|
21
|
+
calories REAL,
|
|
22
|
+
description TEXT,
|
|
23
|
+
private_note TEXT,
|
|
24
|
+
workout_type INTEGER, -- 0=default, 1=race, 2=workout, 3=long run
|
|
25
|
+
gear_id TEXT,
|
|
26
|
+
raw_json TEXT, -- full Strava response as JSON
|
|
27
|
+
details_synced_at TEXT, -- last successful detail fetch (NULL = needs backfill)
|
|
28
|
+
synced_at TEXT DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
-- Time-series streams (HR, power, pace over time)
|
|
32
|
+
CREATE TABLE IF NOT EXISTS streams (
|
|
33
|
+
activity_id INTEGER PRIMARY KEY,
|
|
34
|
+
time_data TEXT, -- JSON array: seconds from start
|
|
35
|
+
distance_data TEXT, -- JSON array: cumulative meters
|
|
36
|
+
heartrate_data TEXT, -- JSON array
|
|
37
|
+
watts_data TEXT, -- JSON array
|
|
38
|
+
cadence_data TEXT, -- JSON array
|
|
39
|
+
altitude_data TEXT, -- JSON array
|
|
40
|
+
velocity_data TEXT, -- JSON array: m/s
|
|
41
|
+
FOREIGN KEY (activity_id) REFERENCES activities(id)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
-- Athlete profile
|
|
45
|
+
CREATE TABLE IF NOT EXISTS athlete (
|
|
46
|
+
id INTEGER PRIMARY KEY,
|
|
47
|
+
firstname TEXT,
|
|
48
|
+
lastname TEXT,
|
|
49
|
+
weight REAL, -- kg
|
|
50
|
+
ftp INTEGER, -- functional threshold power (watts)
|
|
51
|
+
max_heartrate INTEGER,
|
|
52
|
+
raw_json TEXT,
|
|
53
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- Training goals
|
|
57
|
+
CREATE TABLE IF NOT EXISTS goals (
|
|
58
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
+
event_name TEXT, -- "Ironman 70.3 Oceanside"
|
|
60
|
+
event_date TEXT, -- ISO 8601
|
|
61
|
+
event_type TEXT, -- triathlon, marathon, ultra, century
|
|
62
|
+
notes TEXT, -- constraints, injuries, etc.
|
|
63
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
-- Sync metadata
|
|
67
|
+
CREATE TABLE IF NOT EXISTS sync_log (
|
|
68
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
started_at TEXT,
|
|
70
|
+
completed_at TEXT,
|
|
71
|
+
activities_synced INTEGER,
|
|
72
|
+
details_synced INTEGER,
|
|
73
|
+
status TEXT -- success, failed, partial
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
-- Indexes for common queries
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(start_date);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_activities_sport ON activities(sport_type);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_activities_sport_date ON activities(sport_type, start_date);
|
|
80
|
+
|
|
81
|
+
-- Useful views
|
|
82
|
+
DROP VIEW IF EXISTS weekly_volume;
|
|
83
|
+
CREATE VIEW weekly_volume AS
|
|
84
|
+
SELECT
|
|
85
|
+
strftime('%Y-W%W', start_date) AS week,
|
|
86
|
+
sport_type,
|
|
87
|
+
COUNT(*) AS sessions,
|
|
88
|
+
ROUND(SUM(moving_time) / 3600.0, 1) AS hours,
|
|
89
|
+
ROUND(SUM(distance) / 1000.0, 1) AS km,
|
|
90
|
+
ROUND(AVG(average_heartrate), 0) AS avg_hr,
|
|
91
|
+
ROUND(AVG(suffer_score), 0) AS avg_effort
|
|
92
|
+
FROM activities
|
|
93
|
+
GROUP BY week, sport_type
|
|
94
|
+
ORDER BY week DESC, sport_type;
|
|
95
|
+
|
|
96
|
+
DROP VIEW IF EXISTS recent_activities;
|
|
97
|
+
CREATE VIEW recent_activities AS
|
|
98
|
+
SELECT
|
|
99
|
+
date(start_date) AS date,
|
|
100
|
+
sport_type,
|
|
101
|
+
name,
|
|
102
|
+
moving_time / 60 AS minutes,
|
|
103
|
+
ROUND(distance / 1000.0, 1) AS km,
|
|
104
|
+
ROUND(average_heartrate, 0) AS hr,
|
|
105
|
+
suffer_score
|
|
106
|
+
FROM activities
|
|
107
|
+
ORDER BY start_date DESC
|
|
108
|
+
LIMIT 50;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-side helper types and zone narrowing for the Garmin converter.
|
|
3
|
+
*/
|
|
4
|
+
import type { AthleteZones, Sport } from "../schema/training-plan.js";
|
|
5
|
+
import type { TargetTypeDTO } from "./dto.js";
|
|
6
|
+
export interface SportZones {
|
|
7
|
+
hrZones?: {
|
|
8
|
+
zone: number;
|
|
9
|
+
name: string;
|
|
10
|
+
hrLow: number;
|
|
11
|
+
hrHigh: number;
|
|
12
|
+
}[];
|
|
13
|
+
paceZones?: {
|
|
14
|
+
zone: string | number;
|
|
15
|
+
name: string;
|
|
16
|
+
pace: string;
|
|
17
|
+
}[];
|
|
18
|
+
}
|
|
19
|
+
export interface TargetResolution {
|
|
20
|
+
targetType: TargetTypeDTO;
|
|
21
|
+
targetValueOne?: number;
|
|
22
|
+
targetValueTwo?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ConvertContext {
|
|
25
|
+
sportZones: SportZones;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Narrow the plan's AthleteZones to the HR and pace zone tables for one sport.
|
|
29
|
+
* run and trailrun both read zones.run; other sports return {} (no zone data).
|
|
30
|
+
*/
|
|
31
|
+
export declare function getSportZones(zones: AthleteZones, sport: Sport): SportZones;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-side helper types and zone narrowing for the Garmin converter.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Narrow the plan's AthleteZones to the HR and pace zone tables for one sport.
|
|
6
|
+
* run and trailrun both read zones.run; other sports return {} (no zone data).
|
|
7
|
+
*/
|
|
8
|
+
export function getSportZones(zones, sport) {
|
|
9
|
+
if ((sport === "run" || sport === "trailrun") && zones.run) {
|
|
10
|
+
return {
|
|
11
|
+
hrZones: zones.run.hr?.zones.map((z) => ({
|
|
12
|
+
zone: z.zone,
|
|
13
|
+
name: z.name,
|
|
14
|
+
hrLow: z.hrLow,
|
|
15
|
+
hrHigh: z.hrHigh,
|
|
16
|
+
})),
|
|
17
|
+
paceZones: zones.run.pace?.zones.map((z) => ({
|
|
18
|
+
zone: z.zone,
|
|
19
|
+
name: z.name,
|
|
20
|
+
pace: z.pace,
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a Claude Coach TrainingPlan into Garmin sync entries.
|
|
3
|
+
*
|
|
4
|
+
* Pure and deterministic: no clock, no network. Rest days, race entries, and
|
|
5
|
+
* out-of-scope sports (bike/swim) are skipped. Each remaining workout becomes a
|
|
6
|
+
* { calendarDate, workout } entry compatible with the Garmin MCP upload_workouts.
|
|
7
|
+
*/
|
|
8
|
+
import type { TrainingPlan, TrainingWeek, TrainingDay, Workout, Sport } from "../schema/training-plan.js";
|
|
9
|
+
import { type SportTypeDTO, type WorkoutDTO, type GarminSyncEntry } from "./dto.js";
|
|
10
|
+
import { type ConvertContext } from "./context.js";
|
|
11
|
+
/** Map a Claude Coach sport to a Garmin sport type, or null if not schedulable. */
|
|
12
|
+
export declare function mapSport(sport: Sport): SportTypeDTO | null;
|
|
13
|
+
/** Derive a short plan tag from event initials + two-digit event year. */
|
|
14
|
+
export declare function derivePlanTag(plan: TrainingPlan): string;
|
|
15
|
+
/** Build the tagged workout name, e.g. "[HIO26] W3 Tue — Tempo". */
|
|
16
|
+
export declare function workoutName(tag: string, week: TrainingWeek, day: TrainingDay, workout: Workout): string;
|
|
17
|
+
export declare function convertWorkout(workout: Workout, sportType: SportTypeDTO, ctx: ConvertContext, name: string): WorkoutDTO;
|
|
18
|
+
export interface WorkoutFilter {
|
|
19
|
+
/** Export only the workout with this id. */
|
|
20
|
+
workoutId?: string;
|
|
21
|
+
/** Export only workouts on this ISO date (YYYY-MM-DD). */
|
|
22
|
+
date?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ConvertOptions {
|
|
25
|
+
tag?: string;
|
|
26
|
+
filter?: WorkoutFilter;
|
|
27
|
+
}
|
|
28
|
+
export declare function convertPlan(plan: TrainingPlan, options?: ConvertOptions): GarminSyncEntry[];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a Claude Coach TrainingPlan into Garmin sync entries.
|
|
3
|
+
*
|
|
4
|
+
* Pure and deterministic: no clock, no network. Rest days, race entries, and
|
|
5
|
+
* out-of-scope sports (bike/swim) are skipped. Each remaining workout becomes a
|
|
6
|
+
* { calendarDate, workout } entry compatible with the Garmin MCP upload_workouts.
|
|
7
|
+
*/
|
|
8
|
+
import { SPORT_TYPES } from "./dto.js";
|
|
9
|
+
import { getSportZones } from "./context.js";
|
|
10
|
+
import { buildStructuredSteps, buildSimpleSteps } from "./steps.js";
|
|
11
|
+
const STOP_WORDS = new Set(["the", "of", "and", "a", "70.3", "703"]);
|
|
12
|
+
/** Map a Claude Coach sport to a Garmin sport type, or null if not schedulable. */
|
|
13
|
+
export function mapSport(sport) {
|
|
14
|
+
switch (sport) {
|
|
15
|
+
case "run":
|
|
16
|
+
case "trailrun":
|
|
17
|
+
// Garmin has no distinct trail-running workout sport; a trail run is a
|
|
18
|
+
// running workout with identical steps/targets.
|
|
19
|
+
return SPORT_TYPES.running;
|
|
20
|
+
case "strength":
|
|
21
|
+
return SPORT_TYPES.strength_training;
|
|
22
|
+
default:
|
|
23
|
+
return null; // rest, race, bike, swim, brick -> skip
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Derive a short plan tag from event initials + two-digit event year. */
|
|
27
|
+
export function derivePlanTag(plan) {
|
|
28
|
+
const initials = plan.meta.event
|
|
29
|
+
.split(/\s+/)
|
|
30
|
+
.filter((word) => word.length > 0 && !STOP_WORDS.has(word.toLowerCase()))
|
|
31
|
+
.map((word) => word[0].toUpperCase())
|
|
32
|
+
.filter((ch) => /[A-Z]/.test(ch))
|
|
33
|
+
.join("");
|
|
34
|
+
const year = plan.meta.eventDate.slice(2, 4);
|
|
35
|
+
return `${initials}${year}`;
|
|
36
|
+
}
|
|
37
|
+
const DAY_ABBREV = {
|
|
38
|
+
monday: "Mon",
|
|
39
|
+
tuesday: "Tue",
|
|
40
|
+
wednesday: "Wed",
|
|
41
|
+
thursday: "Thu",
|
|
42
|
+
friday: "Fri",
|
|
43
|
+
saturday: "Sat",
|
|
44
|
+
sunday: "Sun",
|
|
45
|
+
};
|
|
46
|
+
function titleCase(s) {
|
|
47
|
+
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
|
|
48
|
+
}
|
|
49
|
+
/** Build the tagged workout name, e.g. "[HIO26] W3 Tue — Tempo". */
|
|
50
|
+
export function workoutName(tag, week, day, workout) {
|
|
51
|
+
const abbrev = DAY_ABBREV[day.dayOfWeek.toLowerCase()] ?? day.dayOfWeek.slice(0, 3);
|
|
52
|
+
return `[${tag}] W${week.weekNumber} ${abbrev} — ${titleCase(workout.type)}`;
|
|
53
|
+
}
|
|
54
|
+
export function convertWorkout(workout, sportType, ctx, name) {
|
|
55
|
+
const steps = workout.structure
|
|
56
|
+
? buildStructuredSteps(workout.structure, ctx)
|
|
57
|
+
: buildSimpleSteps(workout, ctx);
|
|
58
|
+
return {
|
|
59
|
+
workoutName: name,
|
|
60
|
+
sportType,
|
|
61
|
+
workoutSegments: [
|
|
62
|
+
{
|
|
63
|
+
segmentOrder: 1,
|
|
64
|
+
sportType,
|
|
65
|
+
workoutSteps: steps,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Structural validation — throws with a per-workout reason on failure. */
|
|
71
|
+
function validateSchedulableWorkout(workout, name) {
|
|
72
|
+
if (!workout.structure && workout.durationMinutes === undefined) {
|
|
73
|
+
throw new Error(`Workout "${workout.name}" (${name}) has neither a structure nor durationMinutes — cannot build a Garmin workout.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** True when this workout passes the (optional) subset filter. */
|
|
77
|
+
function matchesFilter(workout, day, filter) {
|
|
78
|
+
if (!filter)
|
|
79
|
+
return true;
|
|
80
|
+
if (filter.workoutId !== undefined && workout.id !== filter.workoutId)
|
|
81
|
+
return false;
|
|
82
|
+
if (filter.date !== undefined && day.date !== filter.date)
|
|
83
|
+
return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
export function convertPlan(plan, options = {}) {
|
|
87
|
+
if (!plan.weeks) {
|
|
88
|
+
throw new Error("Plan has no weeks array.");
|
|
89
|
+
}
|
|
90
|
+
const tag = options.tag ?? derivePlanTag(plan);
|
|
91
|
+
const entries = [];
|
|
92
|
+
for (const week of plan.weeks) {
|
|
93
|
+
for (const day of week.days) {
|
|
94
|
+
for (const workout of day.workouts) {
|
|
95
|
+
if (!matchesFilter(workout, day, options.filter))
|
|
96
|
+
continue;
|
|
97
|
+
const sportType = mapSport(workout.sport);
|
|
98
|
+
if (!sportType)
|
|
99
|
+
continue; // rest, race, bike, swim -> skip
|
|
100
|
+
const name = workoutName(tag, week, day, workout);
|
|
101
|
+
validateSchedulableWorkout(workout, name);
|
|
102
|
+
const ctx = { sportZones: getSportZones(plan.zones, workout.sport) };
|
|
103
|
+
entries.push({
|
|
104
|
+
calendarDate: day.date,
|
|
105
|
+
workout: convertWorkout(workout, sportType, ctx, name),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (options.filter && entries.length === 0) {
|
|
111
|
+
const desc = options.filter.workoutId
|
|
112
|
+
? `id "${options.filter.workoutId}"`
|
|
113
|
+
: `date "${options.filter.date}"`;
|
|
114
|
+
throw new Error(`Filter matched no schedulable workout (${desc}).`);
|
|
115
|
+
}
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Garmin Connect workout DTO types and enum constants.
|
|
3
|
+
*
|
|
4
|
+
* These shapes mirror Garmin Connect's native workout JSON, which the Garmin
|
|
5
|
+
* MCP `upload_workouts` tool consumes. Output shapes only — no plan-side imports.
|
|
6
|
+
*
|
|
7
|
+
* Enum IDs pinned by the design spec:
|
|
8
|
+
* repeat end condition -> conditionTypeId 7 / "iterations"
|
|
9
|
+
* heart-rate-zone target -> workoutTargetTypeId 4
|
|
10
|
+
* pace-zone target -> workoutTargetTypeId 6
|
|
11
|
+
* no-target -> workoutTargetTypeId 1
|
|
12
|
+
*/
|
|
13
|
+
export interface SportTypeDTO {
|
|
14
|
+
sportTypeId: number;
|
|
15
|
+
sportTypeKey: string;
|
|
16
|
+
}
|
|
17
|
+
export interface StepTypeDTO {
|
|
18
|
+
stepTypeId: number;
|
|
19
|
+
stepTypeKey: string;
|
|
20
|
+
}
|
|
21
|
+
export interface EndConditionDTO {
|
|
22
|
+
conditionTypeId: number;
|
|
23
|
+
conditionTypeKey: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TargetTypeDTO {
|
|
26
|
+
workoutTargetTypeId: number;
|
|
27
|
+
workoutTargetTypeKey: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ExecutableStepDTO {
|
|
30
|
+
type: "ExecutableStepDTO";
|
|
31
|
+
stepId: number;
|
|
32
|
+
stepOrder: number;
|
|
33
|
+
stepType: StepTypeDTO;
|
|
34
|
+
endCondition: EndConditionDTO;
|
|
35
|
+
endConditionValue: number;
|
|
36
|
+
targetType: TargetTypeDTO;
|
|
37
|
+
targetValueOne?: number;
|
|
38
|
+
targetValueTwo?: number;
|
|
39
|
+
description?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface RepeatGroupDTO {
|
|
42
|
+
type: "RepeatGroupDTO";
|
|
43
|
+
stepId: number;
|
|
44
|
+
stepOrder: number;
|
|
45
|
+
stepType: StepTypeDTO;
|
|
46
|
+
numberOfIterations: number;
|
|
47
|
+
endCondition: EndConditionDTO;
|
|
48
|
+
workoutSteps: ExecutableStepDTO[];
|
|
49
|
+
}
|
|
50
|
+
export type WorkoutStepDTO = ExecutableStepDTO | RepeatGroupDTO;
|
|
51
|
+
export interface WorkoutSegmentDTO {
|
|
52
|
+
segmentOrder: number;
|
|
53
|
+
sportType: SportTypeDTO;
|
|
54
|
+
workoutSteps: WorkoutStepDTO[];
|
|
55
|
+
}
|
|
56
|
+
export interface WorkoutDTO {
|
|
57
|
+
workoutName: string;
|
|
58
|
+
sportType: SportTypeDTO;
|
|
59
|
+
workoutSegments: WorkoutSegmentDTO[];
|
|
60
|
+
}
|
|
61
|
+
export interface GarminSyncEntry {
|
|
62
|
+
calendarDate: string;
|
|
63
|
+
workout: WorkoutDTO;
|
|
64
|
+
}
|
|
65
|
+
export declare const SPORT_TYPES: {
|
|
66
|
+
readonly running: {
|
|
67
|
+
readonly sportTypeId: 1;
|
|
68
|
+
readonly sportTypeKey: "running";
|
|
69
|
+
};
|
|
70
|
+
readonly strength_training: {
|
|
71
|
+
readonly sportTypeId: 5;
|
|
72
|
+
readonly sportTypeKey: "strength_training";
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export declare const STEP_TYPES: {
|
|
76
|
+
readonly warmup: {
|
|
77
|
+
readonly stepTypeId: 1;
|
|
78
|
+
readonly stepTypeKey: "warmup";
|
|
79
|
+
};
|
|
80
|
+
readonly cooldown: {
|
|
81
|
+
readonly stepTypeId: 2;
|
|
82
|
+
readonly stepTypeKey: "cooldown";
|
|
83
|
+
};
|
|
84
|
+
readonly interval: {
|
|
85
|
+
readonly stepTypeId: 3;
|
|
86
|
+
readonly stepTypeKey: "interval";
|
|
87
|
+
};
|
|
88
|
+
readonly recovery: {
|
|
89
|
+
readonly stepTypeId: 4;
|
|
90
|
+
readonly stepTypeKey: "recovery";
|
|
91
|
+
};
|
|
92
|
+
readonly rest: {
|
|
93
|
+
readonly stepTypeId: 5;
|
|
94
|
+
readonly stepTypeKey: "rest";
|
|
95
|
+
};
|
|
96
|
+
readonly repeat: {
|
|
97
|
+
readonly stepTypeId: 6;
|
|
98
|
+
readonly stepTypeKey: "repeat";
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
export declare const END_CONDITIONS: {
|
|
102
|
+
readonly lapButton: {
|
|
103
|
+
readonly conditionTypeId: 1;
|
|
104
|
+
readonly conditionTypeKey: "lap.button";
|
|
105
|
+
};
|
|
106
|
+
readonly time: {
|
|
107
|
+
readonly conditionTypeId: 2;
|
|
108
|
+
readonly conditionTypeKey: "time";
|
|
109
|
+
};
|
|
110
|
+
readonly distance: {
|
|
111
|
+
readonly conditionTypeId: 3;
|
|
112
|
+
readonly conditionTypeKey: "distance";
|
|
113
|
+
};
|
|
114
|
+
readonly iterations: {
|
|
115
|
+
readonly conditionTypeId: 7;
|
|
116
|
+
readonly conditionTypeKey: "iterations";
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
export declare const TARGET_TYPES: {
|
|
120
|
+
readonly noTarget: {
|
|
121
|
+
readonly workoutTargetTypeId: 1;
|
|
122
|
+
readonly workoutTargetTypeKey: "no.target";
|
|
123
|
+
};
|
|
124
|
+
readonly heartRateZone: {
|
|
125
|
+
readonly workoutTargetTypeId: 4;
|
|
126
|
+
readonly workoutTargetTypeKey: "heart.rate.zone";
|
|
127
|
+
};
|
|
128
|
+
readonly paceZone: {
|
|
129
|
+
readonly workoutTargetTypeId: 6;
|
|
130
|
+
readonly workoutTargetTypeKey: "pace.zone";
|
|
131
|
+
};
|
|
132
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Garmin Connect workout DTO types and enum constants.
|
|
3
|
+
*
|
|
4
|
+
* These shapes mirror Garmin Connect's native workout JSON, which the Garmin
|
|
5
|
+
* MCP `upload_workouts` tool consumes. Output shapes only — no plan-side imports.
|
|
6
|
+
*
|
|
7
|
+
* Enum IDs pinned by the design spec:
|
|
8
|
+
* repeat end condition -> conditionTypeId 7 / "iterations"
|
|
9
|
+
* heart-rate-zone target -> workoutTargetTypeId 4
|
|
10
|
+
* pace-zone target -> workoutTargetTypeId 6
|
|
11
|
+
* no-target -> workoutTargetTypeId 1
|
|
12
|
+
*/
|
|
13
|
+
export const SPORT_TYPES = {
|
|
14
|
+
running: { sportTypeId: 1, sportTypeKey: "running" },
|
|
15
|
+
strength_training: { sportTypeId: 5, sportTypeKey: "strength_training" },
|
|
16
|
+
};
|
|
17
|
+
export const STEP_TYPES = {
|
|
18
|
+
warmup: { stepTypeId: 1, stepTypeKey: "warmup" },
|
|
19
|
+
cooldown: { stepTypeId: 2, stepTypeKey: "cooldown" },
|
|
20
|
+
interval: { stepTypeId: 3, stepTypeKey: "interval" },
|
|
21
|
+
recovery: { stepTypeId: 4, stepTypeKey: "recovery" },
|
|
22
|
+
rest: { stepTypeId: 5, stepTypeKey: "rest" },
|
|
23
|
+
repeat: { stepTypeId: 6, stepTypeKey: "repeat" },
|
|
24
|
+
};
|
|
25
|
+
export const END_CONDITIONS = {
|
|
26
|
+
lapButton: { conditionTypeId: 1, conditionTypeKey: "lap.button" },
|
|
27
|
+
time: { conditionTypeId: 2, conditionTypeKey: "time" },
|
|
28
|
+
distance: { conditionTypeId: 3, conditionTypeKey: "distance" },
|
|
29
|
+
iterations: { conditionTypeId: 7, conditionTypeKey: "iterations" },
|
|
30
|
+
};
|
|
31
|
+
export const TARGET_TYPES = {
|
|
32
|
+
noTarget: { workoutTargetTypeId: 1, workoutTargetTypeKey: "no.target" },
|
|
33
|
+
heartRateZone: { workoutTargetTypeId: 4, workoutTargetTypeKey: "heart.rate.zone" },
|
|
34
|
+
paceZone: { workoutTargetTypeId: 6, workoutTargetTypeKey: "pace.zone" },
|
|
35
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { convertPlan } from "./convert.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a running pace string into speed (metres per second), the unit Garmin
|
|
3
|
+
* pace.zone targets use. Supports "M:SS/km", "MM:SS/mi", and bare "M:SS"
|
|
4
|
+
* (assumed per-kilometre). Throws on unparseable input.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parsePaceToMetersPerSecond(pace: string): number;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a running pace string into speed (metres per second), the unit Garmin
|
|
3
|
+
* pace.zone targets use. Supports "M:SS/km", "MM:SS/mi", and bare "M:SS"
|
|
4
|
+
* (assumed per-kilometre). Throws on unparseable input.
|
|
5
|
+
*/
|
|
6
|
+
const METERS_PER_MILE = 1609.344;
|
|
7
|
+
const METERS_PER_KM = 1000;
|
|
8
|
+
export function parsePaceToMetersPerSecond(pace) {
|
|
9
|
+
const trimmed = pace.trim();
|
|
10
|
+
const match = trimmed.match(/^(\d+):(\d{2})(?:\s*\/\s*(km|mi))?$/i);
|
|
11
|
+
if (!match) {
|
|
12
|
+
throw new Error(`Unparseable pace: "${pace}"`);
|
|
13
|
+
}
|
|
14
|
+
const minutes = Number(match[1]);
|
|
15
|
+
const seconds = Number(match[2]);
|
|
16
|
+
const unit = (match[3] ?? "km").toLowerCase();
|
|
17
|
+
const totalSeconds = minutes * 60 + seconds;
|
|
18
|
+
if (totalSeconds <= 0) {
|
|
19
|
+
throw new Error(`Pace must be greater than zero: "${pace}"`);
|
|
20
|
+
}
|
|
21
|
+
const meters = unit === "mi" ? METERS_PER_MILE : METERS_PER_KM;
|
|
22
|
+
return meters / totalSeconds;
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build Garmin workout steps from a Claude Coach workout.
|
|
3
|
+
*
|
|
4
|
+
* Structured workouts map warmup/main/cooldown steps to ExecutableStepDTO and
|
|
5
|
+
* interval sets to RepeatGroupDTO. Simple workouts synthesize warmup(~10%) /
|
|
6
|
+
* main(~80%) / cooldown(~10%), matching the old FIT exporter heuristic.
|
|
7
|
+
*/
|
|
8
|
+
import type { StructuredWorkout, Workout } from "../schema/training-plan.js";
|
|
9
|
+
import { type WorkoutStepDTO } from "./dto.js";
|
|
10
|
+
import type { ConvertContext } from "./context.js";
|
|
11
|
+
export declare function buildStructuredSteps(structure: StructuredWorkout, ctx: ConvertContext): WorkoutStepDTO[];
|
|
12
|
+
export declare function buildSimpleSteps(workout: Workout, ctx: ConvertContext): WorkoutStepDTO[];
|