@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,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,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;
|