@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,88 @@
1
+ /**
2
+ * Training Plan JSON Schema
3
+ *
4
+ * Designed to be comprehensive enough for export to:
5
+ * - Zwift (.zwo workouts)
6
+ * - Garmin Connect (.fit workouts)
7
+ * - TrainingPeaks
8
+ * - Other training platforms
9
+ */
10
+ // Default preferences (metric)
11
+ export const defaultPreferences = {
12
+ swim: "meters",
13
+ bike: "kilometers",
14
+ run: "kilometers",
15
+ firstDayOfWeek: "monday",
16
+ };
17
+ // ============================================================================
18
+ // Example/Template
19
+ // ============================================================================
20
+ export const exampleWorkout = {
21
+ id: "week1-tue-swim",
22
+ sport: "swim",
23
+ type: "technique",
24
+ name: "Technique + Endurance",
25
+ description: "Focus on catch and pull mechanics with aerobic base work",
26
+ durationMinutes: 60,
27
+ distanceMeters: 2500,
28
+ primaryZone: "Zone 2",
29
+ targetHR: { low: 120, high: 135 },
30
+ structure: {
31
+ warmup: [
32
+ {
33
+ type: "warmup",
34
+ name: "Easy swim",
35
+ duration: { unit: "meters", value: 300 },
36
+ intensity: { unit: "css_offset", value: 15, description: "CSS + 15s/100m" },
37
+ },
38
+ {
39
+ type: "warmup",
40
+ name: "Drill set",
41
+ duration: { unit: "meters", value: 200 },
42
+ intensity: { unit: "rpe", value: 3, description: "Easy" },
43
+ notes: "4x50m: catch-up, fingertip drag, fist drill, swim",
44
+ },
45
+ ],
46
+ main: [
47
+ {
48
+ type: "interval_set",
49
+ name: "Threshold set",
50
+ repeats: 5,
51
+ steps: [
52
+ {
53
+ type: "work",
54
+ duration: { unit: "meters", value: 100 },
55
+ intensity: { unit: "css_offset", value: 0, description: "CSS pace" },
56
+ },
57
+ {
58
+ type: "rest",
59
+ duration: { unit: "seconds", value: 15 },
60
+ intensity: { unit: "rpe", value: 1 },
61
+ },
62
+ ],
63
+ },
64
+ {
65
+ type: "work",
66
+ name: "Aerobic pull",
67
+ duration: { unit: "meters", value: 800 },
68
+ intensity: { unit: "css_offset", value: 10, description: "CSS + 10s" },
69
+ notes: "With pull buoy, focus on rotation",
70
+ },
71
+ ],
72
+ cooldown: [
73
+ {
74
+ type: "cooldown",
75
+ name: "Easy swim",
76
+ duration: { unit: "meters", value: 200 },
77
+ intensity: { unit: "rpe", value: 2 },
78
+ },
79
+ ],
80
+ totalDuration: { unit: "minutes", value: 60 },
81
+ estimatedTSS: 45,
82
+ },
83
+ humanReadable: `Warm-up: 300m easy, 4x50m drills
84
+ Main: 5x100m @ CSS, 15s rest
85
+ 800m pull @ CSS+10
86
+ Cool-down: 200m easy`,
87
+ completed: false,
88
+ };
@@ -0,0 +1,23 @@
1
+ import type { Tokens } from "../lib/config.js";
2
+ import { type ReadRateLimit } from "./rate-limit.js";
3
+ import type { StravaActivity, StravaAthlete, StravaDetailedActivity } from "./types.js";
4
+ export declare function getAthlete(tokens: Tokens): Promise<StravaAthlete>;
5
+ export declare function getActivities(tokens: Tokens, after: number, before?: number, page?: number, perPage?: number): Promise<StravaActivity[]>;
6
+ export declare function getAllActivities(tokens: Tokens, afterDate: Date): Promise<StravaActivity[]>;
7
+ export interface DetailFetchOptions {
8
+ retries?: number;
9
+ retryDelayMs?: number;
10
+ }
11
+ export type ActivityDetailResult = {
12
+ status: "ok";
13
+ activity: StravaDetailedActivity;
14
+ rateLimit: ReadRateLimit | null;
15
+ } | {
16
+ status: "not-found";
17
+ } | {
18
+ status: "rate-limited";
19
+ } | {
20
+ status: "error";
21
+ httpStatus: number;
22
+ };
23
+ export declare function getActivityDetail(tokens: Tokens, id: number, opts?: DetailFetchOptions): Promise<ActivityDetailResult>;
@@ -0,0 +1,88 @@
1
+ import { log } from "../lib/logging.js";
2
+ import { parseReadRateLimit } from "./rate-limit.js";
3
+ const API_BASE = "https://www.strava.com/api/v3";
4
+ async function fetchWithRetry(url, options, retries = 3) {
5
+ const response = await fetch(url, options);
6
+ if (response.status === 429) {
7
+ const retryAfter = parseInt(response.headers.get("retry-after") || "60");
8
+ log.warn(`Rate limited. Waiting ${retryAfter}s...`);
9
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
10
+ return fetchWithRetry(url, options, retries);
11
+ }
12
+ if (!response.ok && retries > 0) {
13
+ log.warn(`Request failed (${response.status}), retrying...`);
14
+ await new Promise((resolve) => setTimeout(resolve, 1000));
15
+ return fetchWithRetry(url, options, retries - 1);
16
+ }
17
+ return response;
18
+ }
19
+ export async function getAthlete(tokens) {
20
+ const response = await fetchWithRetry(`${API_BASE}/athlete`, {
21
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error(`Failed to fetch athlete: ${response.statusText}`);
25
+ }
26
+ return response.json();
27
+ }
28
+ export async function getActivities(tokens, after, before, page = 1, perPage = 100) {
29
+ const url = new URL(`${API_BASE}/athlete/activities`);
30
+ url.searchParams.set("after", after.toString());
31
+ if (before) {
32
+ url.searchParams.set("before", before.toString());
33
+ }
34
+ url.searchParams.set("page", page.toString());
35
+ url.searchParams.set("per_page", perPage.toString());
36
+ const response = await fetchWithRetry(url.toString(), {
37
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
38
+ });
39
+ if (!response.ok) {
40
+ throw new Error(`Failed to fetch activities: ${response.statusText}`);
41
+ }
42
+ return response.json();
43
+ }
44
+ export async function getAllActivities(tokens, afterDate) {
45
+ const after = Math.floor(afterDate.getTime() / 1000);
46
+ const activities = [];
47
+ let page = 1;
48
+ const perPage = 100;
49
+ log.start(`Fetching activities since ${afterDate.toISOString().split("T")[0]}...`);
50
+ while (true) {
51
+ const batch = await getActivities(tokens, after, undefined, page, perPage);
52
+ activities.push(...batch);
53
+ log.progress(` Fetched ${activities.length} activities...`);
54
+ if (batch.length < perPage) {
55
+ break;
56
+ }
57
+ page++;
58
+ // Small delay to be nice to the API
59
+ await new Promise((resolve) => setTimeout(resolve, 100));
60
+ }
61
+ log.progressEnd();
62
+ log.success(`Fetched ${activities.length} activities total`);
63
+ return activities;
64
+ }
65
+ // Deliberately not fetchWithRetry: the detail pass must stop on 429, not wait
66
+ // out the 15-minute window inside a sync run.
67
+ export async function getActivityDetail(tokens, id, opts = {}) {
68
+ const { retries = 2, retryDelayMs = 1000 } = opts;
69
+ const response = await fetch(`${API_BASE}/activities/${id}`, {
70
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
71
+ });
72
+ if (response.status === 429)
73
+ return { status: "rate-limited" };
74
+ if (response.status === 404)
75
+ return { status: "not-found" };
76
+ if (!response.ok) {
77
+ if (retries > 0) {
78
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
79
+ return getActivityDetail(tokens, id, { retries: retries - 1, retryDelayMs });
80
+ }
81
+ return { status: "error", httpStatus: response.status };
82
+ }
83
+ return {
84
+ status: "ok",
85
+ activity: await response.json(),
86
+ rateLimit: parseReadRateLimit(response.headers),
87
+ };
88
+ }
@@ -0,0 +1,11 @@
1
+ import type { Tokens } from "../lib/config.js";
2
+ import { type DetailFetchOptions } from "./api.js";
3
+ export interface DetailSyncResult {
4
+ fetched: number;
5
+ total: number;
6
+ stopped: "complete" | "budget" | "rate-limited";
7
+ }
8
+ export declare function syncActivityDetails(tokens: Tokens, opts?: {
9
+ delayMs?: number;
10
+ fetchOptions?: DetailFetchOptions;
11
+ }): Promise<DetailSyncResult>;
@@ -0,0 +1,50 @@
1
+ import { log } from "../lib/logging.js";
2
+ import { getActivityDetail } from "./api.js";
3
+ import { isBudgetExhausted } from "./rate-limit.js";
4
+ import { applyActivityDetail, getDetailCandidateIds, markDetailsSynced } from "./store.js";
5
+ const RESUME_HINT = "run sync again in ~15 min to continue.";
6
+ export async function syncActivityDetails(tokens, opts = {}) {
7
+ const delayMs = opts.delayMs ?? 100;
8
+ const ids = getDetailCandidateIds();
9
+ const total = ids.length;
10
+ if (total === 0) {
11
+ log.success("Activity details are up to date");
12
+ return { fetched: 0, total: 0, stopped: "complete" };
13
+ }
14
+ log.start(`Fetching details for ${total} activities...`);
15
+ let fetched = 0;
16
+ for (const id of ids) {
17
+ const result = await getActivityDetail(tokens, id, opts.fetchOptions);
18
+ if (result.status === "rate-limited") {
19
+ log.progressEnd();
20
+ log.warn(`Rate limited — ${fetched} of ${total} details fetched, ${RESUME_HINT}`);
21
+ return { fetched, total, stopped: "rate-limited" };
22
+ }
23
+ if (result.status === "ok") {
24
+ applyActivityDetail(result.activity);
25
+ fetched++;
26
+ if (fetched % 25 === 0) {
27
+ log.progress(` Fetched ${fetched}/${total} details...`);
28
+ }
29
+ if (result.rateLimit && isBudgetExhausted(result.rateLimit)) {
30
+ log.progressEnd();
31
+ log.warn(`Detail budget reached — ${fetched} of ${total} fetched, ${RESUME_HINT}`);
32
+ return { fetched, total, stopped: "budget" };
33
+ }
34
+ }
35
+ else if (result.status === "not-found") {
36
+ // Deleted on Strava since the summary sync; mark so it isn't retried forever.
37
+ markDetailsSynced(id);
38
+ fetched++;
39
+ }
40
+ else {
41
+ log.warn(`Failed to fetch details for activity ${id} (HTTP ${result.httpStatus}), will retry next sync`);
42
+ }
43
+ if (delayMs > 0) {
44
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
45
+ }
46
+ }
47
+ log.progressEnd();
48
+ log.success(`Fetched details for ${fetched} of ${total} activities`);
49
+ return { fetched, total, stopped: "complete" };
50
+ }
@@ -0,0 +1,4 @@
1
+ import { type Tokens } from "../lib/config.js";
2
+ export declare function authorize(): Promise<Tokens>;
3
+ export declare function refreshTokens(): Promise<Tokens>;
4
+ export declare function getValidTokens(): Promise<Tokens>;
@@ -0,0 +1,113 @@
1
+ import { createServer } from "http";
2
+ import { URL } from "url";
3
+ import open from "open";
4
+ import { loadConfig, loadTokens, saveTokens, tokensExist, tokensExpired, } from "../lib/config.js";
5
+ import { log } from "../lib/logging.js";
6
+ const REDIRECT_PORT = 8765;
7
+ const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
8
+ const AUTHORIZE_URL = "https://www.strava.com/oauth/authorize";
9
+ const TOKEN_URL = "https://www.strava.com/oauth/token";
10
+ export async function authorize() {
11
+ const config = loadConfig();
12
+ const { client_id, client_secret } = config.strava;
13
+ const authUrl = new URL(AUTHORIZE_URL);
14
+ authUrl.searchParams.set("client_id", client_id);
15
+ authUrl.searchParams.set("response_type", "code");
16
+ authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
17
+ authUrl.searchParams.set("scope", "activity:read_all");
18
+ authUrl.searchParams.set("approval_prompt", "auto");
19
+ log.info("Opening browser for Strava authorization...");
20
+ const code = await new Promise((resolve, reject) => {
21
+ const server = createServer((req, res) => {
22
+ const url = new URL(req.url, `http://localhost:${REDIRECT_PORT}`);
23
+ if (url.pathname === "/callback") {
24
+ const code = url.searchParams.get("code");
25
+ const error = url.searchParams.get("error");
26
+ if (error) {
27
+ res.writeHead(400, { "Content-Type": "text/html" });
28
+ res.end(`<h1>Authorization Failed</h1><p>${error}</p>`);
29
+ server.close();
30
+ reject(new Error(`Authorization failed: ${error}`));
31
+ return;
32
+ }
33
+ if (code) {
34
+ res.writeHead(200, { "Content-Type": "text/html" });
35
+ res.end("<h1>✅ Authorization Successful!</h1><p>You can close this window.</p>");
36
+ server.close();
37
+ resolve(code);
38
+ }
39
+ }
40
+ });
41
+ server.listen(REDIRECT_PORT, () => {
42
+ open(authUrl.toString());
43
+ });
44
+ server.on("error", (err) => {
45
+ reject(new Error(`Failed to start callback server: ${err.message}`));
46
+ });
47
+ });
48
+ log.success("Authorization code received, exchanging for tokens...");
49
+ const tokenResponse = await fetch(TOKEN_URL, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({
53
+ client_id,
54
+ client_secret,
55
+ code,
56
+ grant_type: "authorization_code",
57
+ }),
58
+ });
59
+ if (!tokenResponse.ok) {
60
+ const error = await tokenResponse.text();
61
+ throw new Error(`Token exchange failed: ${error}`);
62
+ }
63
+ const data = await tokenResponse.json();
64
+ const tokens = {
65
+ access_token: data.access_token,
66
+ refresh_token: data.refresh_token,
67
+ expires_at: data.expires_at,
68
+ athlete_id: data.athlete.id,
69
+ };
70
+ saveTokens(tokens);
71
+ log.success(`Authenticated as ${data.athlete.firstname} ${data.athlete.lastname}`);
72
+ return tokens;
73
+ }
74
+ export async function refreshTokens() {
75
+ const config = loadConfig();
76
+ const oldTokens = loadTokens();
77
+ const { client_id, client_secret } = config.strava;
78
+ log.start("Refreshing access token...");
79
+ const response = await fetch(TOKEN_URL, {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({
83
+ client_id,
84
+ client_secret,
85
+ refresh_token: oldTokens.refresh_token,
86
+ grant_type: "refresh_token",
87
+ }),
88
+ });
89
+ if (!response.ok) {
90
+ const error = await response.text();
91
+ throw new Error(`Token refresh failed: ${error}`);
92
+ }
93
+ const data = await response.json();
94
+ const tokens = {
95
+ access_token: data.access_token,
96
+ refresh_token: data.refresh_token,
97
+ expires_at: data.expires_at,
98
+ athlete_id: oldTokens.athlete_id,
99
+ };
100
+ saveTokens(tokens);
101
+ log.success("Token refreshed");
102
+ return tokens;
103
+ }
104
+ export async function getValidTokens() {
105
+ if (!tokensExist()) {
106
+ return authorize();
107
+ }
108
+ const tokens = loadTokens();
109
+ if (tokensExpired(tokens)) {
110
+ return refreshTokens();
111
+ }
112
+ return tokens;
113
+ }
@@ -0,0 +1,9 @@
1
+ export interface ReadRateLimit {
2
+ shortTermUsage: number;
3
+ shortTermLimit: number;
4
+ dailyUsage: number;
5
+ dailyLimit: number;
6
+ }
7
+ export declare const BUDGET_MARGIN = 5;
8
+ export declare function parseReadRateLimit(headers: Headers): ReadRateLimit | null;
9
+ export declare function isBudgetExhausted(rl: ReadRateLimit, margin?: number): boolean;
@@ -0,0 +1,16 @@
1
+ export const BUDGET_MARGIN = 5;
2
+ export function parseReadRateLimit(headers) {
3
+ const limit = headers.get("x-readratelimit-limit");
4
+ const usage = headers.get("x-readratelimit-usage");
5
+ if (!limit || !usage)
6
+ return null;
7
+ const [shortTermLimit, dailyLimit] = limit.split(",").map(Number);
8
+ const [shortTermUsage, dailyUsage] = usage.split(",").map(Number);
9
+ if ([shortTermLimit, dailyLimit, shortTermUsage, dailyUsage].some((n) => !Number.isFinite(n))) {
10
+ return null;
11
+ }
12
+ return { shortTermUsage, shortTermLimit, dailyUsage, dailyLimit };
13
+ }
14
+ export function isBudgetExhausted(rl, margin = BUDGET_MARGIN) {
15
+ return rl.shortTermUsage >= rl.shortTermLimit - margin || rl.dailyUsage >= rl.dailyLimit - margin;
16
+ }
@@ -0,0 +1,13 @@
1
+ import type { StravaActivity, StravaDetailedActivity } from "./types.js";
2
+ export declare function escapeString(str: string | null | undefined): string;
3
+ export declare function upsertSummaryActivity(activity: StravaActivity): void;
4
+ export declare function insertAthlete(athlete: {
5
+ id: number;
6
+ firstname: string;
7
+ lastname: string;
8
+ weight?: number;
9
+ ftp?: number;
10
+ }): void;
11
+ export declare function applyActivityDetail(detail: StravaDetailedActivity): void;
12
+ export declare function markDetailsSynced(id: number): void;
13
+ export declare function getDetailCandidateIds(windowDays?: number): number[];
@@ -0,0 +1,107 @@
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
+ }
7
+ // Summary-only upsert. On conflict, detail columns (description, private_note,
8
+ // calories, raw_json, details_synced_at) are deliberately not updated: the
9
+ // summary endpoint doesn't return them, and REPLACE would wipe fetched details.
10
+ export function upsertSummaryActivity(activity) {
11
+ const sql = `
12
+ INSERT INTO activities (
13
+ id, name, sport_type, start_date, elapsed_time, moving_time,
14
+ distance, total_elevation_gain, average_speed, max_speed,
15
+ average_heartrate, max_heartrate, average_watts, max_watts,
16
+ weighted_average_watts, kilojoules, suffer_score, average_cadence,
17
+ calories, description, workout_type, gear_id, raw_json, synced_at
18
+ ) VALUES (
19
+ ${activity.id},
20
+ ${escapeString(activity.name)},
21
+ ${escapeString(activity.sport_type)},
22
+ ${escapeString(activity.start_date)},
23
+ ${activity.elapsed_time ?? "NULL"},
24
+ ${activity.moving_time ?? "NULL"},
25
+ ${activity.distance ?? "NULL"},
26
+ ${activity.total_elevation_gain ?? "NULL"},
27
+ ${activity.average_speed ?? "NULL"},
28
+ ${activity.max_speed ?? "NULL"},
29
+ ${activity.average_heartrate ?? "NULL"},
30
+ ${activity.max_heartrate ?? "NULL"},
31
+ ${activity.average_watts ?? "NULL"},
32
+ ${activity.max_watts ?? "NULL"},
33
+ ${activity.weighted_average_watts ?? "NULL"},
34
+ ${activity.kilojoules ?? "NULL"},
35
+ ${activity.suffer_score ?? "NULL"},
36
+ ${activity.average_cadence ?? "NULL"},
37
+ ${activity.calories ?? "NULL"},
38
+ ${escapeString(activity.description)},
39
+ ${activity.workout_type ?? "NULL"},
40
+ ${escapeString(activity.gear_id)},
41
+ ${escapeString(JSON.stringify(activity))},
42
+ datetime('now')
43
+ )
44
+ ON CONFLICT(id) DO UPDATE SET
45
+ name = excluded.name,
46
+ sport_type = excluded.sport_type,
47
+ start_date = excluded.start_date,
48
+ elapsed_time = excluded.elapsed_time,
49
+ moving_time = excluded.moving_time,
50
+ distance = excluded.distance,
51
+ total_elevation_gain = excluded.total_elevation_gain,
52
+ average_speed = excluded.average_speed,
53
+ max_speed = excluded.max_speed,
54
+ average_heartrate = excluded.average_heartrate,
55
+ max_heartrate = excluded.max_heartrate,
56
+ average_watts = excluded.average_watts,
57
+ max_watts = excluded.max_watts,
58
+ weighted_average_watts = excluded.weighted_average_watts,
59
+ kilojoules = excluded.kilojoules,
60
+ suffer_score = excluded.suffer_score,
61
+ average_cadence = excluded.average_cadence,
62
+ workout_type = excluded.workout_type,
63
+ gear_id = excluded.gear_id,
64
+ synced_at = excluded.synced_at;
65
+ `;
66
+ execute(sql);
67
+ }
68
+ export function insertAthlete(athlete) {
69
+ const sql = `
70
+ INSERT OR REPLACE INTO athlete (id, firstname, lastname, weight, ftp, raw_json, updated_at)
71
+ VALUES (
72
+ ${athlete.id},
73
+ ${escapeString(athlete.firstname)},
74
+ ${escapeString(athlete.lastname)},
75
+ ${athlete.weight ?? "NULL"},
76
+ ${athlete.ftp ?? "NULL"},
77
+ ${escapeString(JSON.stringify(athlete))},
78
+ datetime('now')
79
+ );
80
+ `;
81
+ execute(sql);
82
+ }
83
+ export function applyActivityDetail(detail) {
84
+ execute(`
85
+ UPDATE activities SET
86
+ description = ${escapeString(detail.description)},
87
+ private_note = ${escapeString(detail.private_note)},
88
+ calories = ${detail.calories ?? "NULL"},
89
+ raw_json = ${escapeString(JSON.stringify(detail))},
90
+ details_synced_at = datetime('now')
91
+ WHERE id = ${detail.id};
92
+ `);
93
+ }
94
+ export function markDetailsSynced(id) {
95
+ execute(`UPDATE activities SET details_synced_at = datetime('now') WHERE id = ${id};`);
96
+ }
97
+ // NULL marker = full-history backfill; the window re-fetches recent rows so
98
+ // notes written after the first sync are picked up. Newest first, so the most
99
+ // coaching-relevant rows land before any rate-limit stop.
100
+ export function getDetailCandidateIds(windowDays = 14) {
101
+ return queryJson(`
102
+ SELECT id FROM activities
103
+ WHERE details_synced_at IS NULL
104
+ OR start_date >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-${windowDays} days')
105
+ ORDER BY start_date DESC;
106
+ `).map((row) => row.id);
107
+ }
@@ -0,0 +1,49 @@
1
+ export interface StravaAthlete {
2
+ id: number;
3
+ firstname: string;
4
+ lastname: string;
5
+ weight?: number;
6
+ ftp?: number;
7
+ }
8
+ export interface StravaTokenResponse {
9
+ token_type: string;
10
+ access_token: string;
11
+ refresh_token: string;
12
+ expires_at: number;
13
+ expires_in: number;
14
+ athlete: StravaAthlete;
15
+ }
16
+ export interface StravaActivity {
17
+ id: number;
18
+ name: string;
19
+ sport_type: string;
20
+ start_date: string;
21
+ elapsed_time: number;
22
+ moving_time: number;
23
+ distance: number;
24
+ total_elevation_gain: number;
25
+ average_speed: number;
26
+ max_speed: number;
27
+ average_heartrate?: number;
28
+ max_heartrate?: number;
29
+ average_watts?: number;
30
+ max_watts?: number;
31
+ weighted_average_watts?: number;
32
+ kilojoules?: number;
33
+ suffer_score?: number;
34
+ average_cadence?: number;
35
+ calories?: number;
36
+ description?: string;
37
+ workout_type?: number;
38
+ gear_id?: string;
39
+ }
40
+ export interface StravaDetailedActivity extends StravaActivity {
41
+ private_note?: string;
42
+ }
43
+ export interface StravaStream {
44
+ type: string;
45
+ data: number[];
46
+ series_type: string;
47
+ original_size: number;
48
+ resolution: string;
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ERG/MRC Export
3
+ *
4
+ * Generates ERG/MRC workout files for indoor cycling trainers.
5
+ * Widely supported by TrainerRoad, Zwift, PerfPRO, Golden Cheetah, and others.
6
+ *
7
+ * - ERG format: Uses absolute watts
8
+ * - MRC format: Uses percentage of FTP (more portable)
9
+ *
10
+ * We generate MRC format since it scales to each user's FTP.
11
+ */
12
+ import type { Workout, Sport } from "../../../schema/training-plan.js";
13
+ import type { Settings } from "../../stores/settings.js";
14
+ /**
15
+ * Check if a sport is supported by ERG/MRC export
16
+ * Only cycling workouts make sense for trainer files
17
+ */
18
+ export declare function isErgSupported(sport: Sport): boolean;
19
+ /**
20
+ * Generate a complete MRC file for a workout
21
+ */
22
+ export declare function generateMrc(workout: Workout, _settings: Settings): string;
23
+ /**
24
+ * Generate ERG file (absolute watts) - requires FTP
25
+ */
26
+ export declare function generateErg(workout: Workout, settings: Settings): string;