@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.
Files changed (64) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +98 -0
  3. package/bin/claude-coach.js +10 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +530 -0
  6. package/dist/db/client.d.ts +8 -0
  7. package/dist/db/client.js +111 -0
  8. package/dist/db/migrate.d.ts +2 -0
  9. package/dist/db/migrate.js +30 -0
  10. package/dist/db/schema.sql +108 -0
  11. package/dist/garmin/context.d.ts +31 -0
  12. package/dist/garmin/context.js +25 -0
  13. package/dist/garmin/convert.d.ts +28 -0
  14. package/dist/garmin/convert.js +117 -0
  15. package/dist/garmin/dto.d.ts +132 -0
  16. package/dist/garmin/dto.js +35 -0
  17. package/dist/garmin/index.d.ts +3 -0
  18. package/dist/garmin/index.js +1 -0
  19. package/dist/garmin/pace.d.ts +6 -0
  20. package/dist/garmin/pace.js +23 -0
  21. package/dist/garmin/steps.d.ts +12 -0
  22. package/dist/garmin/steps.js +147 -0
  23. package/dist/garmin/targets.d.ts +15 -0
  24. package/dist/garmin/targets.js +85 -0
  25. package/dist/lib/config.d.ts +27 -0
  26. package/dist/lib/config.js +86 -0
  27. package/dist/lib/logging.d.ts +13 -0
  28. package/dist/lib/logging.js +28 -0
  29. package/dist/schema/training-plan.d.ts +288 -0
  30. package/dist/schema/training-plan.js +88 -0
  31. package/dist/strava/api.d.ts +23 -0
  32. package/dist/strava/api.js +88 -0
  33. package/dist/strava/details.d.ts +11 -0
  34. package/dist/strava/details.js +50 -0
  35. package/dist/strava/oauth.d.ts +4 -0
  36. package/dist/strava/oauth.js +113 -0
  37. package/dist/strava/rate-limit.d.ts +9 -0
  38. package/dist/strava/rate-limit.js +16 -0
  39. package/dist/strava/store.d.ts +13 -0
  40. package/dist/strava/store.js +107 -0
  41. package/dist/strava/types.d.ts +49 -0
  42. package/dist/strava/types.js +1 -0
  43. package/dist/viewer/lib/export/erg.d.ts +26 -0
  44. package/dist/viewer/lib/export/erg.js +208 -0
  45. package/dist/viewer/lib/export/fit.d.ts +25 -0
  46. package/dist/viewer/lib/export/fit.js +308 -0
  47. package/dist/viewer/lib/export/ics.d.ts +13 -0
  48. package/dist/viewer/lib/export/ics.js +142 -0
  49. package/dist/viewer/lib/export/index.d.ts +50 -0
  50. package/dist/viewer/lib/export/index.js +229 -0
  51. package/dist/viewer/lib/export/zwo.d.ts +21 -0
  52. package/dist/viewer/lib/export/zwo.js +233 -0
  53. package/dist/viewer/lib/utils.d.ts +14 -0
  54. package/dist/viewer/lib/utils.js +125 -0
  55. package/dist/viewer/main.d.ts +5 -0
  56. package/dist/viewer/main.js +6 -0
  57. package/dist/viewer/stores/changes.d.ts +21 -0
  58. package/dist/viewer/stores/changes.js +49 -0
  59. package/dist/viewer/stores/plan.d.ts +4 -0
  60. package/dist/viewer/stores/plan.js +19 -0
  61. package/dist/viewer/stores/settings.d.ts +53 -0
  62. package/dist/viewer/stores/settings.js +215 -0
  63. package/package.json +67 -0
  64. 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,2 @@
1
+ export declare function ensureColumns(): void;
2
+ export declare function migrate(): void;
@@ -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,3 @@
1
+ export { convertPlan } from "./convert.js";
2
+ export type { WorkoutFilter, ConvertOptions } from "./convert.js";
3
+ export type { GarminSyncEntry, WorkoutDTO } from "./dto.js";
@@ -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[];