ergzone-mcp 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mauro Malvestio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # ergzone-mcp
2
+
3
+ Unofficial MCP server for [ErgZone](https://www.erg.zone) (Concept2 rowing). Manage workouts, results and stats from your AI assistant. **Zero install, zero dependencies** — runs from GitHub via `npx` (Node ≥ 18).
4
+
5
+ > Not affiliated with ErgZone / Concept2. Personal use.
6
+
7
+ ## Setup
8
+
9
+ You need a Concept2 Logbook account (the one you use on log.concept2.com).
10
+
11
+ **Claude Code:**
12
+
13
+ ```bash
14
+ claude mcp add ergzone \
15
+ -e ERGZONE_LOGBOOK_EMAIL=you@example.com \
16
+ -e ERGZONE_LOGBOOK_PASSWORD=yourpassword \
17
+ -- npx -y github:malveo/ergzone-mcp
18
+ ```
19
+
20
+ **Claude Desktop** — add to `claude_desktop_config.json`:
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "ergzone": {
26
+ "command": "npx",
27
+ "args": ["-y", "github:malveo/ergzone-mcp"],
28
+ "env": {
29
+ "ERGZONE_LOGBOOK_EMAIL": "you@example.com",
30
+ "ERGZONE_LOGBOOK_PASSWORD": "yourpassword"
31
+ }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ The server logs in for you and caches the token (`~/.config/ergzone-mcp/`, refreshed on expiry).
38
+ Prefer not to store your password? Use `ERGZONE_SESSION_TOKEN` instead (copied from
39
+ `localStorage.SESSION_TOKEN` on admin.erg.zone).
40
+
41
+ ## Tools
42
+
43
+ | Tool | What it does |
44
+ |------|------|
45
+ | `auth_check` | who am I |
46
+ | `list_workouts` / `get_workout` | browse workouts |
47
+ | `create_workout` / `update_workout` / `delete_workout` | manage workouts |
48
+ | `build_intervals` | preview intervals before saving |
49
+ | `list_my_results` / `get_result` | your sessions + telemetry |
50
+ | `my_stats` / `analyze_result` | totals, HR zones, pace/SPI per interval |
51
+
52
+ Ask in plain language, e.g. *"create an SPM ladder 16 to 30"* or *"analyze my last result"*.
53
+
54
+ ## Settings
55
+
56
+ | Var | Notes |
57
+ |-----|------|
58
+ | `ERGZONE_LOGBOOK_EMAIL` + `ERGZONE_LOGBOOK_PASSWORD` | auto-login (recommended) |
59
+ | `ERGZONE_SESSION_TOKEN` | alternative to the two above |
60
+ | `ERGZONE_TRACK_ID` | default workout list (optional) |
61
+ | `ERGZONE_ALLOW_WRITE` | `false` = read-only |
62
+
63
+ ## Notes
64
+
65
+ Auto-login stores your Logbook password and replicates the Logbook sign-in, so it may break if
66
+ Concept2 changes that page, and automating login may be against their Terms of Service.
67
+
68
+ Works on macOS, Linux and Windows (Node ≥ 18.7). On Windows the `npx github:...` form needs
69
+ [Git](https://git-scm.com) installed; the token cache lives under `%APPDATA%\ergzone-mcp`.
70
+
71
+ MIT licensed.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { serve } from '../src/mcp.mjs';
3
+
4
+ serve();
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "ergzone-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Unofficial MCP server for ErgZone (Concept2 rowing). Zero runtime dependencies.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ergzone-mcp": "./bin/ergzone-mcp.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18.7"
11
+ },
12
+ "os": [
13
+ "darwin",
14
+ "linux",
15
+ "win32"
16
+ ],
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "start": "node bin/ergzone-mcp.mjs"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/malveo/ergzone-mcp.git"
28
+ },
29
+ "homepage": "https://github.com/malveo/ergzone-mcp#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/malveo/ergzone-mcp/issues"
32
+ },
33
+ "keywords": [
34
+ "mcp",
35
+ "model-context-protocol",
36
+ "ergzone",
37
+ "concept2",
38
+ "rowing",
39
+ "erg"
40
+ ],
41
+ "license": "MIT",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,220 @@
1
+ // Headless ErgZone login via Concept2 Logbook credentials.
2
+ // Pure Node fetch, zero dependencies. Small composable functions so the flow is
3
+ // easy to follow and to patch if ErgZone changes a single step.
4
+ //
5
+ // Flow (verified):
6
+ // 1. GET log.concept2.com/login -> CSRF _token + session cookie
7
+ // 2. POST log.concept2.com/login -> authenticated session
8
+ // 3. GET log.concept2.com/oauth/authorize -> consent page + CSRF _token
9
+ // 4. POST log.concept2.com/oauth/authorize -> 302 callback?code=...
10
+ // 5. GET production.erg.zone/auth/logbook/callback?code -> session_id (the SESSION_TOKEN)
11
+
12
+ import { createHash } from 'node:crypto';
13
+ import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ // --- configuration (ErgZone's public OAuth client) ---
18
+
19
+ export const AUTH_CONFIG = {
20
+ c2Base: 'https://log.concept2.com',
21
+ ergApi: 'https://production.erg.zone/api',
22
+ clientId: 'RDj8SvdqKgPdKAMcDQAuwLjIIOPCYrjHG9tJrqpJ',
23
+ redirectUri: 'https://production.erg.zone/auth/logbook/callback',
24
+ scope: 'user:read,results:write',
25
+ userAgent: 'ergzone-mcp',
26
+ };
27
+
28
+ export function authorizeUrl(cfg = AUTH_CONFIG) {
29
+ const q = new URLSearchParams({
30
+ client_id: cfg.clientId,
31
+ redirect_uri: cfg.redirectUri,
32
+ response_type: 'code',
33
+ scope: cfg.scope,
34
+ });
35
+ return `${cfg.c2Base}/oauth/authorize?${q}`;
36
+ }
37
+
38
+ // --- cookie jar (per-host, in memory) ---
39
+
40
+ export function createJar() {
41
+ return { hosts: {} };
42
+ }
43
+
44
+ function hostOf(url) {
45
+ return new URL(url).host;
46
+ }
47
+
48
+ export function storeCookies(jar, url, res) {
49
+ const host = hostOf(url);
50
+ const bag = (jar.hosts[host] ||= {});
51
+ const list = typeof res.headers.getSetCookie === 'function'
52
+ ? res.headers.getSetCookie()
53
+ : [res.headers.get('set-cookie')].filter(Boolean);
54
+ for (const cookie of list) {
55
+ const [pair] = cookie.split(';');
56
+ const eq = pair.indexOf('=');
57
+ if (eq > 0) bag[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
58
+ }
59
+ }
60
+
61
+ export function cookieHeader(jar, url) {
62
+ const bag = jar.hosts[hostOf(url)] || {};
63
+ return Object.entries(bag).map(([k, v]) => `${k}=${v}`).join('; ');
64
+ }
65
+
66
+ // One request with the jar, manual redirects (so we can read Location).
67
+ export async function jarFetch(jar, url, opts = {}) {
68
+ const headers = {
69
+ 'User-Agent': AUTH_CONFIG.userAgent,
70
+ Accept: 'text/html,application/json',
71
+ ...(opts.headers || {}),
72
+ };
73
+ const cookie = cookieHeader(jar, url);
74
+ if (cookie) headers.Cookie = cookie;
75
+ const res = await fetch(url, { ...opts, headers, redirect: 'manual' });
76
+ storeCookies(jar, url, res);
77
+ return res;
78
+ }
79
+
80
+ // --- HTML parsing helpers (no DOM, regex on simple Laravel forms) ---
81
+
82
+ export function hiddenInput(html, name) {
83
+ const re = new RegExp(`<input[^>]*name=["']${name}["'][^>]*>`, 'i');
84
+ const tag = html.match(re)?.[0] || '';
85
+ return tag.match(/value=["']([^"']*)["']/i)?.[1] ?? null;
86
+ }
87
+
88
+ export function formAction(html, matcher) {
89
+ const re = new RegExp(`<form[^>]*action=["']([^"']*${matcher}[^"']*)["']`, 'i');
90
+ return html.match(re)?.[1]?.replace(/&amp;/g, '&') ?? null;
91
+ }
92
+
93
+ export class AuthError extends Error {
94
+ constructor(message, step) {
95
+ super(message);
96
+ this.name = 'AuthError';
97
+ this.step = step;
98
+ }
99
+ }
100
+
101
+ // --- individual steps ---
102
+
103
+ // 1+2: authenticate against Concept2 Logbook. Mutates the jar with the session cookie.
104
+ export async function logbookSignIn(jar, { email, password }, cfg = AUTH_CONFIG) {
105
+ const loginUrl = `${cfg.c2Base}/login`;
106
+ const page = await jarFetch(jar, loginUrl);
107
+ const html = await page.text();
108
+ const token = hiddenInput(html, '_token');
109
+ if (!token) throw new AuthError('CSRF token not found on login page', 'login-page');
110
+
111
+ const body = new URLSearchParams({ _token: token, username: email, password, remember: '1' });
112
+ const res = await jarFetch(jar, loginUrl, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Referer: loginUrl },
115
+ body: body.toString(),
116
+ });
117
+ if (res.status !== 302) {
118
+ throw new AuthError('Logbook sign-in failed (wrong credentials, captcha, or form change)', 'login-post');
119
+ }
120
+ return true;
121
+ }
122
+
123
+ // 3+4: approve the ErgZone OAuth consent, return the authorization code.
124
+ export async function authorizeErgZone(jar, cfg = AUTH_CONFIG) {
125
+ const url = authorizeUrl(cfg);
126
+ const page = await jarFetch(jar, url);
127
+
128
+ // Already consented in this session -> immediate 302 with the code.
129
+ if (page.status === 302) {
130
+ const code = new URL(page.headers.get('location'), cfg.c2Base).searchParams.get('code');
131
+ if (code) return code;
132
+ }
133
+
134
+ const html = await page.text();
135
+ const token = hiddenInput(html, '_token');
136
+ if (!token) throw new AuthError('CSRF token not found on consent page', 'consent-page');
137
+ const action = formAction(html, 'authorize') || url;
138
+
139
+ const body = new URLSearchParams({ _token: token, approve: 'Approve Erg Zone' });
140
+ const res = await jarFetch(jar, action, {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Referer: url },
143
+ body: body.toString(),
144
+ });
145
+ if (res.status !== 302) throw new AuthError('OAuth approve did not redirect', 'consent-post');
146
+
147
+ const code = new URL(res.headers.get('location'), cfg.c2Base).searchParams.get('code');
148
+ if (!code) throw new AuthError('No authorization code in callback redirect', 'consent-post');
149
+ return code;
150
+ }
151
+
152
+ // 5: exchange the code at the ErgZone callback, return the session token.
153
+ export async function exchangeCode(jar, code, cfg = AUTH_CONFIG) {
154
+ const res = await jarFetch(jar, `${cfg.redirectUri}?code=${encodeURIComponent(code)}`);
155
+ const text = await res.text();
156
+ // Webview callback embeds: ...postMessage(JSON.stringify({session_id:'SFMyNTY...'}))
157
+ const token = text.match(/session_id\s*:\s*['"](SFMyNTY[^'"]+)['"]/)?.[1]
158
+ || text.match(/\/auth\/(SFMyNTY[^/?#"'\\\s]+)/)?.[1];
159
+ if (!token) throw new AuthError('Session token not found in callback response', 'callback');
160
+ return token;
161
+ }
162
+
163
+ // Full chain: credentials -> session token.
164
+ export async function loginWithLogbook({ email, password }, cfg = AUTH_CONFIG) {
165
+ if (!email || !password) throw new AuthError('Missing Logbook email/password', 'config');
166
+ const jar = createJar();
167
+ await logbookSignIn(jar, { email, password }, cfg);
168
+ const code = await authorizeErgZone(jar, cfg);
169
+ return exchangeCode(jar, code, cfg);
170
+ }
171
+
172
+ // --- token cache (file, 0600) ---
173
+
174
+ export function cacheDir() {
175
+ // Platform-aware base: %APPDATA% on Windows, XDG/~/.config elsewhere.
176
+ const base = process.platform === 'win32'
177
+ ? (process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'))
178
+ : (process.env.XDG_CONFIG_HOME || join(homedir(), '.config'));
179
+ return join(base, 'ergzone-mcp');
180
+ }
181
+
182
+ export function cachePath(email) {
183
+ const tag = email ? createHash('sha256').update(email).digest('hex').slice(0, 12) : 'default';
184
+ return join(cacheDir(), `token-${tag}`);
185
+ }
186
+
187
+ export function readCachedToken(email) {
188
+ const file = cachePath(email);
189
+ try {
190
+ const t = readFileSync(file, 'utf8').trim();
191
+ return t || null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ export function writeCachedToken(email, token) {
198
+ const dir = cacheDir();
199
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
200
+ writeFileSync(cachePath(email), token, { mode: 0o600 });
201
+ }
202
+
203
+ export function clearCachedToken(email) {
204
+ try {
205
+ rmSync(cachePath(email));
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+ }
210
+
211
+ // Cached token if present, else a fresh login. force=true bypasses the cache.
212
+ export async function getSessionToken({ email, password, force = false }, cfg = AUTH_CONFIG) {
213
+ if (!force) {
214
+ const cached = readCachedToken(email);
215
+ if (cached) return cached;
216
+ }
217
+ const token = await loginWithLogbook({ email, password }, cfg);
218
+ writeCachedToken(email, token);
219
+ return token;
220
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // GraphQL client for ErgZone. Zero dependencies: uses the global fetch (Node >=18).
2
+ // Token resolution order:
3
+ // 1. ERGZONE_SESSION_TOKEN (explicit, used as-is)
4
+ // 2. ERGZONE_LOGBOOK_EMAIL + ERGZONE_LOGBOOK_PASSWORD (headless auto-login, cached)
5
+ // On an auth failure with Logbook credentials, the token is refreshed once and the call retried.
6
+
7
+ import { getSessionToken, clearCachedToken, AuthError } from './auth.mjs';
8
+
9
+ const ENDPOINT = process.env.ERGZONE_ENDPOINT || 'https://production.erg.zone/api';
10
+
11
+ const EXPLICIT_TOKEN = process.env.ERGZONE_SESSION_TOKEN || null;
12
+ const LOGBOOK = {
13
+ email: process.env.ERGZONE_LOGBOOK_EMAIL || null,
14
+ password: process.env.ERGZONE_LOGBOOK_PASSWORD || null,
15
+ };
16
+
17
+ export const DEFAULT_TRACK_ID = process.env.ERGZONE_TRACK_ID || '';
18
+ export const WRITE_ENABLED = process.env.ERGZONE_ALLOW_WRITE !== 'false';
19
+
20
+ // Normalized error: kind = config | network | auth | infra | graphql
21
+ export class ErgzoneError extends Error {
22
+ constructor(message, { kind = 'graphql', detail } = {}) {
23
+ super(message);
24
+ this.name = 'ErgzoneError';
25
+ this.kind = kind;
26
+ this.detail = detail;
27
+ }
28
+ }
29
+
30
+ const hasLogbook = () => Boolean(LOGBOOK.email && LOGBOOK.password);
31
+
32
+ let cachedToken = EXPLICIT_TOKEN;
33
+
34
+ // Resolve a usable session token. force=true triggers a fresh Logbook login.
35
+ async function resolveToken(force = false) {
36
+ if (EXPLICIT_TOKEN) return EXPLICIT_TOKEN;
37
+ if (!hasLogbook()) {
38
+ throw new ErgzoneError(
39
+ 'No credentials: set ERGZONE_SESSION_TOKEN, or ERGZONE_LOGBOOK_EMAIL + ERGZONE_LOGBOOK_PASSWORD.',
40
+ { kind: 'config' },
41
+ );
42
+ }
43
+ if (force) clearCachedToken(LOGBOOK.email);
44
+ if (!cachedToken || force) {
45
+ try {
46
+ cachedToken = await getSessionToken({ ...LOGBOOK, force });
47
+ } catch (e) {
48
+ if (e instanceof AuthError) throw new ErgzoneError(`Login failed: ${e.message}`, { kind: 'auth', detail: e.step });
49
+ throw e;
50
+ }
51
+ }
52
+ return cachedToken;
53
+ }
54
+
55
+ // Single GraphQL POST with a given token. Returns { data } or throws ErgzoneError.
56
+ async function postGraphQL(token, query, variables) {
57
+ let res;
58
+ try {
59
+ res = await fetch(ENDPOINT, {
60
+ method: 'POST',
61
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ query, variables }),
63
+ });
64
+ } catch (e) {
65
+ throw new ErgzoneError(`Network error: ${e.message}`, { kind: 'network' });
66
+ }
67
+
68
+ const text = await res.text();
69
+ let json;
70
+ try {
71
+ json = JSON.parse(text);
72
+ } catch {
73
+ // ErgZone returns HTML (non-JSON) when the token is expired or on infra errors.
74
+ if (res.status === 401 || res.status === 403 || /login|sign\s*in/i.test(text)) {
75
+ throw new ErgzoneError('Token expired or invalid.', { kind: 'auth', detail: `HTTP ${res.status}` });
76
+ }
77
+ throw new ErgzoneError(`Non-JSON response (HTTP ${res.status}).`, { kind: 'infra', detail: text.slice(0, 200) });
78
+ }
79
+
80
+ if (json.errors && json.errors.length) {
81
+ throw new ErgzoneError(json.errors.map((e) => e.message).join('; '), { kind: 'graphql', detail: json.errors });
82
+ }
83
+ return json.data;
84
+ }
85
+
86
+ export async function gql(query, variables = {}) {
87
+ const token = await resolveToken(false);
88
+ try {
89
+ return await postGraphQL(token, query, variables);
90
+ } catch (e) {
91
+ // Refresh once on auth failure, but only when we can re-login (Logbook creds present).
92
+ if (e instanceof ErgzoneError && e.kind === 'auth' && !EXPLICIT_TOKEN && hasLogbook()) {
93
+ const fresh = await resolveToken(true);
94
+ return postGraphQL(fresh, query, variables);
95
+ }
96
+ if (e instanceof ErgzoneError && e.kind === 'auth' && EXPLICIT_TOKEN) {
97
+ throw new ErgzoneError(
98
+ 'Token expired or invalid. Update ERGZONE_SESSION_TOKEN, or switch to ERGZONE_LOGBOOK_EMAIL/PASSWORD for auto-login.',
99
+ { kind: 'auth' },
100
+ );
101
+ }
102
+ throw e;
103
+ }
104
+ }
105
+
106
+ // Today's date as a Date scalar "YYYY-MM-DD".
107
+ export function todayISO() {
108
+ const d = new Date();
109
+ const p = (n) => String(n).padStart(2, '0');
110
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
111
+ }
@@ -0,0 +1,183 @@
1
+ // Interval builder and validation for ErgZone.
2
+ // Hides the complexity of the "suggested*" fields (intensity / relative target pace):
3
+ // - suggestedPaceBenchmarkGroup: "INTERVAL" (ref another interval) | "A"/"B"... (PR benchmark)
4
+ // - suggestedInterval: 0-based index of the referenced interval (< current position)
5
+ // - suggestedOperator: ONLY "+" (the sign goes into the pace)
6
+ // - suggestedPace: offset sec/500m (negative = faster)
7
+
8
+ // "M:SS" or seconds (number/string) => integer seconds.
9
+ export function parseDuration(v) {
10
+ if (typeof v === 'number') return Math.round(v);
11
+ if (typeof v === 'string') {
12
+ const s = v.trim();
13
+ const m = s.match(/^(\d+):(\d{1,2})$/);
14
+ if (m) return Number(m[1]) * 60 + Number(m[2]);
15
+ if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s));
16
+ }
17
+ throw new Error(`Invalid duration: ${JSON.stringify(v)} (use "M:SS" or seconds)`);
18
+ }
19
+
20
+ // A high-level "segment" -> GraphQL IntervalInput.
21
+ // Segment fields:
22
+ // time | work : duration ("M:SS"/sec) (default if no distance/cals)
23
+ // distance : meters -> type "distance"
24
+ // cals : calories -> type "cals"
25
+ // spm, spmMax : target stroke rate
26
+ // rest : rest after the interval ("M:SS"/sec)
27
+ // undefRest : bool (open/undefined rest)
28
+ // notes, restNotes
29
+ // fasterThanPrev : N -> N sec/500m faster than the previous interval
30
+ // fasterThan : { interval: <1-based>, seconds: N } -> relative to a specific interval
31
+ // benchmark : { group: "A", offset: N } -> relative to a PR benchmark (signed offset)
32
+ function segmentToInterval(seg, idx) {
33
+ const iv = { undefRest: !!seg.undefRest };
34
+
35
+ if (seg.distance != null) {
36
+ iv.type = 'distance';
37
+ iv.value = Math.round(seg.distance);
38
+ } else if (seg.cals != null) {
39
+ iv.type = 'cals';
40
+ iv.value = Math.round(seg.cals);
41
+ } else {
42
+ iv.type = 'time';
43
+ iv.value = parseDuration(seg.time ?? seg.work);
44
+ }
45
+
46
+ if (seg.spm != null) iv.spm = seg.spm;
47
+ if (seg.spmMax != null) iv.spmMax = seg.spmMax;
48
+ if (seg.rest != null) iv.rest = parseDuration(seg.rest);
49
+ if (seg.notes) iv.notes = seg.notes;
50
+ if (seg.restNotes) iv.restNotes = seg.restNotes;
51
+
52
+ // Intensity
53
+ if (seg.fasterThanPrev != null) {
54
+ if (idx === 0) throw new Error('fasterThanPrev is invalid on the first interval (no previous one)');
55
+ iv.suggestedPaceBenchmarkGroup = 'INTERVAL';
56
+ iv.suggestedInterval = idx - 1;
57
+ iv.suggestedOperator = '+';
58
+ iv.suggestedPace = -Math.abs(seg.fasterThanPrev);
59
+ } else if (seg.fasterThan) {
60
+ const ref0 = Number(seg.fasterThan.interval) - 1; // 1-based human -> 0-based
61
+ if (!(ref0 >= 0) || ref0 >= idx) {
62
+ throw new Error(`fasterThan.interval must reference a previous interval (1..${idx})`);
63
+ }
64
+ iv.suggestedPaceBenchmarkGroup = 'INTERVAL';
65
+ iv.suggestedInterval = ref0;
66
+ iv.suggestedOperator = '+';
67
+ iv.suggestedPace = -Math.abs(seg.fasterThan.seconds);
68
+ } else if (seg.benchmark) {
69
+ iv.suggestedPaceBenchmarkGroup = String(seg.benchmark.group);
70
+ iv.suggestedOperator = '+';
71
+ iv.suggestedPace = Number(seg.benchmark.offset);
72
+ }
73
+
74
+ return iv;
75
+ }
76
+
77
+ export function buildIntervals(segments) {
78
+ if (!Array.isArray(segments) || segments.length === 0) {
79
+ throw new Error('segments is empty or invalid');
80
+ }
81
+ return segments.map((s, i) => segmentToInterval(s, i));
82
+ }
83
+
84
+ // --- High-level recipes ---
85
+
86
+ // SPM ladder: e.g. ladder({spmStart:16, spmEnd:30}) -> 16/17, 18/19, ... 30/31, each 1' rest 20s.
87
+ export function ladder({
88
+ spmStart = 16,
89
+ spmEnd = 30,
90
+ step = 2,
91
+ pairWidth = 1,
92
+ work = '1:00',
93
+ rest = '0:20',
94
+ lastRest = false,
95
+ } = {}) {
96
+ const segs = [];
97
+ for (let s = spmStart; s <= spmEnd; s += step) {
98
+ const seg = { time: work, spm: s, rest };
99
+ if (pairWidth) seg.spmMax = s + pairWidth;
100
+ segs.push(seg);
101
+ }
102
+ if (!lastRest && segs.length) delete segs[segs.length - 1].rest;
103
+ return buildIntervals(segs);
104
+ }
105
+
106
+ // Over/under: base + surge blocks. blocks:[{baseWork,baseSpm,surgeWork,surgeSpm}].
107
+ export function overUnder({ blocks = [], restBetween = '1:00' } = {}) {
108
+ if (!blocks.length) throw new Error('overUnder: blocks is empty');
109
+ const segs = [];
110
+ blocks.forEach((b, i) => {
111
+ segs.push({ time: b.baseWork, spm: b.baseSpm });
112
+ const surge = { time: b.surgeWork, spm: b.surgeSpm };
113
+ if (i < blocks.length - 1) surge.rest = restBetween;
114
+ segs.push(surge);
115
+ });
116
+ return buildIntervals(segs);
117
+ }
118
+
119
+ // Progressive intensity: each block repeats a pattern of durations; the first interval
120
+ // of every block is "free" (no target), the others are -faster sec/500m vs the previous one.
121
+ export function progressiveIntensity({
122
+ pattern = ['1:00', '2:00', '1:00', '3:00', '1:00', '4:00'],
123
+ blocks = 2,
124
+ faster = 0.1,
125
+ restBetween = '4:00',
126
+ restAfterLast = false,
127
+ } = {}) {
128
+ const segs = [];
129
+ for (let b = 0; b < blocks; b++) {
130
+ pattern.forEach((dur, j) => {
131
+ const seg = { time: dur };
132
+ if (j > 0) seg.fasterThanPrev = faster; // the first interval of each block stays free
133
+ segs.push(seg);
134
+ });
135
+ const last = segs[segs.length - 1];
136
+ if (b < blocks - 1) last.rest = restBetween;
137
+ else if (restAfterLast) last.rest = restBetween;
138
+ }
139
+ return buildIntervals(segs);
140
+ }
141
+
142
+ // Client-side validation: anticipates the server's cryptic errors.
143
+ export function validateIntervals(intervals) {
144
+ const errs = [];
145
+ intervals.forEach((iv, i) => {
146
+ if (!['time', 'distance', 'cals'].includes(iv.type)) errs.push(`#${i + 1}: invalid type (${iv.type})`);
147
+ if (!(iv.value > 0)) errs.push(`#${i + 1}: missing or <= 0 value`);
148
+ const hasSuggest = iv.suggestedInterval != null || iv.suggestedPace != null || iv.suggestedPaceBenchmarkGroup != null;
149
+ if (hasSuggest) {
150
+ if (iv.suggestedOperator !== '+') errs.push(`#${i + 1}: suggestedOperator must be "+"`);
151
+ if (iv.suggestedInterval != null && iv.suggestedInterval >= i) {
152
+ errs.push(`#${i + 1}: suggestedInterval ${iv.suggestedInterval} must be < ${i} (0-based)`);
153
+ }
154
+ const g = iv.suggestedPaceBenchmarkGroup;
155
+ if (g !== 'INTERVAL' && !/^[A-Z]$/.test(g || '')) errs.push(`#${i + 1}: invalid benchmarkGroup (${g})`);
156
+ if (g === 'INTERVAL' && iv.suggestedInterval == null) errs.push(`#${i + 1}: group INTERVAL requires suggestedInterval`);
157
+ }
158
+ });
159
+ return errs;
160
+ }
161
+
162
+ // Resolves intervals from one of the formats accepted by the tools: intervals | segments | recipe.
163
+ export function resolveIntervals(args) {
164
+ if (Array.isArray(args.intervals)) return args.intervals;
165
+ if (Array.isArray(args.segments)) return buildIntervals(args.segments);
166
+ if (args.recipe && args.recipe.kind) {
167
+ const { kind, ...params } = args.recipe;
168
+ switch (kind) {
169
+ case 'ladder':
170
+ return ladder(params);
171
+ case 'over_under':
172
+ return overUnder(params);
173
+ case 'progressive':
174
+ case 'progressive_intensity':
175
+ return progressiveIntensity(params);
176
+ case 'custom':
177
+ return buildIntervals(params.segments);
178
+ default:
179
+ throw new Error(`Unknown recipe.kind: ${kind}`);
180
+ }
181
+ }
182
+ throw new Error('Provide one of: intervals, segments, recipe');
183
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,100 @@
1
+ // MCP loop over stdio (JSON-RPC 2.0, newline-delimited messages).
2
+ // Implements: initialize, tools/list, tools/call, ping. Zero dependencies.
3
+
4
+ import readline from 'node:readline';
5
+ import { TOOLS } from './tools.mjs';
6
+ import { ErgzoneError, WRITE_ENABLED } from './client.mjs';
7
+
8
+ const SERVER_INFO = { name: 'ergzone-mcp', version: '0.1.0' };
9
+ const PROTOCOL_VERSION = '2024-11-05';
10
+
11
+ function send(msg) {
12
+ process.stdout.write(JSON.stringify(msg) + '\n');
13
+ }
14
+ function reply(id, result) {
15
+ send({ jsonrpc: '2.0', id, result });
16
+ }
17
+ function fail(id, code, message, data) {
18
+ send({ jsonrpc: '2.0', id, error: { code, message, data } });
19
+ }
20
+ function log(...args) {
21
+ // stdout is reserved for the protocol: logs go to stderr.
22
+ process.stderr.write('[ergzone-mcp] ' + args.join(' ') + '\n');
23
+ }
24
+
25
+ async function handle(msg) {
26
+ const { id, method, params } = msg;
27
+
28
+ switch (method) {
29
+ case 'initialize':
30
+ return reply(id, {
31
+ protocolVersion: params?.protocolVersion || PROTOCOL_VERSION,
32
+ capabilities: { tools: {} },
33
+ serverInfo: SERVER_INFO,
34
+ });
35
+
36
+ case 'notifications/initialized':
37
+ case 'initialized':
38
+ return; // notification, no response
39
+
40
+ case 'ping':
41
+ return reply(id, {});
42
+
43
+ case 'tools/list':
44
+ return reply(id, {
45
+ tools: TOOLS.map((t) => ({
46
+ name: t.name,
47
+ description: t.description,
48
+ inputSchema: t.inputSchema,
49
+ })),
50
+ });
51
+
52
+ case 'tools/call': {
53
+ const tool = TOOLS.find((t) => t.name === params?.name);
54
+ if (!tool) return fail(id, -32602, `Unknown tool: ${params?.name}`);
55
+
56
+ if (tool.write && !WRITE_ENABLED) {
57
+ return reply(id, {
58
+ content: [{ type: 'text', text: 'Writes are disabled (ERGZONE_ALLOW_WRITE=false).' }],
59
+ isError: true,
60
+ });
61
+ }
62
+
63
+ try {
64
+ const out = await tool.handler(params.arguments || {});
65
+ const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2);
66
+ return reply(id, { content: [{ type: 'text', text }] });
67
+ } catch (e) {
68
+ const text =
69
+ e instanceof ErgzoneError ? `Error [${e.kind}]: ${e.message}` : `Error: ${e.message}`;
70
+ return reply(id, { content: [{ type: 'text', text }], isError: true });
71
+ }
72
+ }
73
+
74
+ default:
75
+ if (id !== undefined) return fail(id, -32601, `Unsupported method: ${method}`);
76
+ }
77
+ }
78
+
79
+ export function serve() {
80
+ const rl = readline.createInterface({ input: process.stdin });
81
+ rl.on('line', async (line) => {
82
+ const s = line.trim();
83
+ if (!s) return;
84
+ let msg;
85
+ try {
86
+ msg = JSON.parse(s);
87
+ } catch {
88
+ return log('invalid JSON:', s.slice(0, 80));
89
+ }
90
+ try {
91
+ await handle(msg);
92
+ } catch (e) {
93
+ log('handler crash:', e.message);
94
+ if (msg && msg.id !== undefined) fail(msg.id, -32603, 'Internal error');
95
+ }
96
+ });
97
+ // No process.exit() here: when stdin closes we let in-flight async calls drain;
98
+ // Node exits on its own once the event loop is empty.
99
+ log('started. write =', WRITE_ENABLED, 'endpoint =', process.env.ERGZONE_ENDPOINT || 'production');
100
+ }
package/src/tools.mjs ADDED
@@ -0,0 +1,346 @@
1
+ // MCP tool definitions (Tier 1: core training + builder + basic analysis).
2
+ // Each tool: { name, description, inputSchema, handler, write?, destructive? }
3
+
4
+ import { gql, todayISO, DEFAULT_TRACK_ID } from './client.mjs';
5
+ import { resolveIntervals, validateIntervals } from './intervals.mjs';
6
+
7
+ // ---- analysis helpers ----
8
+
9
+ // Concept2 watts from pace (sec/500m). Formula: 2.80 / (pace/500)^3.
10
+ function wattsFromPace(paceSecPer500) {
11
+ if (!paceSecPer500 || paceSecPer500 <= 0) return null;
12
+ return 2.8 / Math.pow(paceSecPer500 / 500, 3);
13
+ }
14
+
15
+ function paceString(sec) {
16
+ if (sec == null) return null;
17
+ const m = Math.floor(sec / 60);
18
+ const s = (sec % 60).toFixed(1).padStart(4, '0');
19
+ return `${m}:${s}`;
20
+ }
21
+
22
+ function hrZone(pct) {
23
+ if (pct == null) return null;
24
+ if (pct < 60) return 'Z1';
25
+ if (pct < 70) return 'Z2';
26
+ if (pct < 80) return 'Z3';
27
+ if (pct < 90) return 'Z4';
28
+ return 'Z5';
29
+ }
30
+
31
+ // ---- reusable GraphQL fragments ----
32
+
33
+ const INTERVAL_DEF = `type value spm spmMax rest undefRest notes restNotes suggestedInterval suggestedOperator suggestedPace suggestedPaceBenchmarkGroup`;
34
+ const INTERVAL_RESULT = `type value rest distance avgPace avgSpm maxSpm avgWatts maxWatts calories avgHr maxHr minHr hrZones strokeCount rateConsistency driveLength driveTime recoveryTime avgForce peakForce avgDragFactor`;
35
+
36
+ export const TOOLS = [
37
+ {
38
+ name: 'auth_check',
39
+ description: 'Verify the token and show the logged-in user (id, name, email, max/resting HR, weight).',
40
+ inputSchema: { type: 'object', properties: {} },
41
+ async handler() {
42
+ const d = await gql(`query{ currentUser{ id name email maxHeartRate restingHeartRate weight weightUnit } }`);
43
+ if (!d.currentUser) throw new Error('No user: invalid token.');
44
+ return d.currentUser;
45
+ },
46
+ },
47
+
48
+ {
49
+ name: 'list_workouts',
50
+ description: 'List the workouts in a track (default: My Workouts). Optional filters: search, limit.',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ trackId: { type: 'string', description: 'Track ID (default from ERGZONE_TRACK_ID)' },
55
+ search: { type: 'string' },
56
+ limit: { type: 'number', default: 50 },
57
+ },
58
+ },
59
+ async handler({ trackId, search, limit = 50 }) {
60
+ const t = trackId || DEFAULT_TRACK_ID;
61
+ if (!t) throw new Error('No trackId (pass trackId or set ERGZONE_TRACK_ID).');
62
+ const d = await gql(
63
+ `query($t:[ID],$s:String){ workouts(trackIds:$t, search:$s){ id title status publishedAt intervalsLength workoutResultsCount } }`,
64
+ { t: [t], s: search || null },
65
+ );
66
+ const ws = (d.workouts || []).slice(0, limit);
67
+ return { count: ws.length, workouts: ws };
68
+ },
69
+ },
70
+
71
+ {
72
+ name: 'get_workout',
73
+ description: 'Full detail of a workout, including all intervals.',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: { id: { type: 'string' } },
77
+ required: ['id'],
78
+ },
79
+ async handler({ id }) {
80
+ const d = await gql(
81
+ `query($id:ID!){ workout(id:$id){ id title status publishedAt description workoutType machineTypes intervals{ ${INTERVAL_DEF} } } }`,
82
+ { id },
83
+ );
84
+ if (!d.workout) throw new Error(`Workout ${id} not found.`);
85
+ return d.workout;
86
+ },
87
+ },
88
+
89
+ {
90
+ name: 'build_intervals',
91
+ description:
92
+ 'Build and validate intervals WITHOUT saving them (preview). Accepts one of: segments (DSL), recipe (ladder|over_under|progressive), intervals (raw). Returns the ready IntervalInput plus any validation errors.',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ segments: { type: 'array', items: { type: 'object' } },
97
+ recipe: { type: 'object', description: 'e.g. {kind:"ladder", spmStart:16, spmEnd:30} | {kind:"progressive", blocks:2, faster:0.1} | {kind:"over_under", blocks:[...]}' },
98
+ intervals: { type: 'array', items: { type: 'object' } },
99
+ },
100
+ },
101
+ async handler(args) {
102
+ const intervals = resolveIntervals(args);
103
+ const errors = validateIntervals(intervals);
104
+ return { count: intervals.length, intervals, valid: errors.length === 0, errors };
105
+ },
106
+ },
107
+
108
+ {
109
+ name: 'create_workout',
110
+ description:
111
+ 'Create a new workout. Intervals from segments | recipe | intervals. publishedAt and workoutType are filled automatically. Validates before sending and reads the result back (round-trip).',
112
+ write: true,
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ title: { type: 'string' },
117
+ description: { type: 'string' },
118
+ trackId: { type: 'string' },
119
+ status: { type: 'string', default: 'published', description: 'published | draft' },
120
+ publishedAt: { type: 'string', description: 'YYYY-MM-DD (default: today)' },
121
+ workoutType: { type: 'string', default: 'row' },
122
+ machineTypes: { type: 'array', items: { type: 'string' } },
123
+ segments: { type: 'array', items: { type: 'object' } },
124
+ recipe: { type: 'object' },
125
+ intervals: { type: 'array', items: { type: 'object' } },
126
+ },
127
+ required: ['title'],
128
+ },
129
+ async handler(args) {
130
+ const trackId = args.trackId || DEFAULT_TRACK_ID;
131
+ if (!trackId) throw new Error('No trackId (pass trackId or set ERGZONE_TRACK_ID).');
132
+ const intervals = resolveIntervals(args);
133
+ const errors = validateIntervals(intervals);
134
+ if (errors.length) throw new Error('Invalid intervals: ' + errors.join('; '));
135
+
136
+ const workout = {
137
+ trackId,
138
+ title: args.title,
139
+ description: args.description || null,
140
+ status: args.status || 'published',
141
+ publishedAt: args.publishedAt || todayISO(),
142
+ workoutType: args.workoutType || 'row',
143
+ machineTypes: args.machineTypes || [],
144
+ amrap: false,
145
+ hasLeaderboard: false,
146
+ intervals,
147
+ };
148
+ const d = await gql(
149
+ `mutation($w:WorkoutInput!){ workoutUpsert(workout:$w){ id title status publishedAt intervals{ ${INTERVAL_DEF} } } }`,
150
+ { w: workout },
151
+ );
152
+ return { created: d.workoutUpsert };
153
+ },
154
+ },
155
+
156
+ {
157
+ name: 'update_workout',
158
+ description: 'Update an existing workout (requires id). Same interval formats as create_workout. If no intervals are passed, only the provided metadata is updated.',
159
+ write: true,
160
+ inputSchema: {
161
+ type: 'object',
162
+ properties: {
163
+ id: { type: 'string' },
164
+ title: { type: 'string' },
165
+ description: { type: 'string' },
166
+ trackId: { type: 'string' },
167
+ status: { type: 'string' },
168
+ publishedAt: { type: 'string' },
169
+ segments: { type: 'array', items: { type: 'object' } },
170
+ recipe: { type: 'object' },
171
+ intervals: { type: 'array', items: { type: 'object' } },
172
+ },
173
+ required: ['id'],
174
+ },
175
+ async handler(args) {
176
+ const trackId = args.trackId || DEFAULT_TRACK_ID;
177
+ if (!trackId) throw new Error('No trackId.');
178
+
179
+ // Fetch the current state for the fields that are not provided.
180
+ const cur = await gql(`query($id:ID!){ workout(id:$id){ title status publishedAt workoutType description } }`, { id: args.id });
181
+ if (!cur.workout) throw new Error(`Workout ${args.id} not found.`);
182
+
183
+ const workout = {
184
+ id: args.id,
185
+ trackId,
186
+ title: args.title ?? cur.workout.title,
187
+ description: args.description ?? cur.workout.description,
188
+ status: args.status ?? cur.workout.status,
189
+ publishedAt: args.publishedAt ?? cur.workout.publishedAt ?? todayISO(),
190
+ workoutType: cur.workout.workoutType || 'row',
191
+ machineTypes: [],
192
+ amrap: false,
193
+ hasLeaderboard: false,
194
+ };
195
+
196
+ const hasIntervals = args.intervals || args.segments || args.recipe;
197
+ if (hasIntervals) {
198
+ const intervals = resolveIntervals(args);
199
+ const errors = validateIntervals(intervals);
200
+ if (errors.length) throw new Error('Invalid intervals: ' + errors.join('; '));
201
+ workout.intervals = intervals;
202
+ }
203
+
204
+ const d = await gql(
205
+ `mutation($w:WorkoutInput!){ workoutUpsert(workout:$w){ id title status publishedAt intervals{ ${INTERVAL_DEF} } } }`,
206
+ { w: workout },
207
+ );
208
+ return { updated: d.workoutUpsert };
209
+ },
210
+ },
211
+
212
+ {
213
+ name: 'delete_workout',
214
+ description: 'Delete a workout. Requires confirm:true (irreversible action).',
215
+ write: true,
216
+ destructive: true,
217
+ inputSchema: {
218
+ type: 'object',
219
+ properties: {
220
+ id: { type: 'string' },
221
+ confirm: { type: 'boolean', description: 'Must be true to proceed.' },
222
+ },
223
+ required: ['id'],
224
+ },
225
+ async handler({ id, confirm }) {
226
+ if (confirm !== true) throw new Error('Deletion not confirmed: pass confirm:true.');
227
+ const d = await gql(`mutation($id:ID!){ workoutDelete(id:$id){ id title } }`, { id });
228
+ return { deleted: d.workoutDelete };
229
+ },
230
+ },
231
+
232
+ {
233
+ name: 'list_my_results',
234
+ description: 'List my results within a date range (YYYY-MM-DD). Default: last 30 days.',
235
+ inputSchema: {
236
+ type: 'object',
237
+ properties: {
238
+ startDate: { type: 'string' },
239
+ endDate: { type: 'string' },
240
+ timezone: { type: 'string' },
241
+ limit: { type: 'number', default: 50 },
242
+ },
243
+ },
244
+ async handler({ startDate, endDate, timezone, limit = 50 }) {
245
+ const d = await gql(
246
+ `query($s:String,$e:String,$tz:String){ workoutResults(startDate:$s, endDate:$e, timezone:$tz){ id status ergType elapsedTime elapsedDistance scoreValue insertedAt workout{ id title } } }`,
247
+ { s: startDate || null, e: endDate || null, tz: timezone || null },
248
+ );
249
+ const rs = (d.workoutResults || []).slice(0, limit);
250
+ return { count: rs.length, results: rs };
251
+ },
252
+ },
253
+
254
+ {
255
+ name: 'get_result',
256
+ description: 'Detail of a result with per-interval telemetry (pace, SPM, watts, HR, force).',
257
+ inputSchema: {
258
+ type: 'object',
259
+ properties: { id: { type: 'string' } },
260
+ required: ['id'],
261
+ },
262
+ async handler({ id }) {
263
+ const d = await gql(
264
+ `query($id:ID!){ workoutResult(id:$id){ id status ergType elapsedTime elapsedDistance scoreValue workout{ id title } intervals{ ${INTERVAL_RESULT} } } }`,
265
+ { id },
266
+ );
267
+ if (!d.workoutResult) throw new Error(`Result ${id} not found.`);
268
+ return d.workoutResult;
269
+ },
270
+ },
271
+
272
+ {
273
+ name: 'my_stats',
274
+ description: 'Aggregate stats (distance/time/calories/days + HR zones) over a period. time: "week"|"month"|"year"|"all".',
275
+ inputSchema: {
276
+ type: 'object',
277
+ properties: {
278
+ time: { type: 'string', default: 'month' },
279
+ ergTypes: { type: 'array', items: { type: 'string' } },
280
+ timezone: { type: 'string' },
281
+ },
282
+ },
283
+ async handler({ time = 'month', ergTypes, timezone }) {
284
+ const d = await gql(
285
+ `query($t:String!,$erg:[String],$tz:String){ myStats(time:$t, ergTypes:$erg, timezone:$tz){ totalDistance totalTime totalCalories totalDays weightedDistance hrZones rates } }`,
286
+ { t: time, erg: ergTypes || null, tz: timezone || null },
287
+ );
288
+ return d.myStats;
289
+ },
290
+ },
291
+
292
+ {
293
+ name: 'analyze_result',
294
+ description:
295
+ 'Coaching analysis of a result: for each interval computes pace, SPI (watts/SPM), %HR and zone. Uses the passed maxHr or the profile value.',
296
+ inputSchema: {
297
+ type: 'object',
298
+ properties: {
299
+ id: { type: 'string' },
300
+ maxHr: { type: 'number', description: 'Max HR for the zones (default: user profile)' },
301
+ },
302
+ required: ['id'],
303
+ },
304
+ async handler({ id, maxHr }) {
305
+ const d = await gql(
306
+ `query($id:ID!){ workoutResult(id:$id){ id elapsedTime elapsedDistance workout{ title } intervals{ ${INTERVAL_RESULT} } } }`,
307
+ { id },
308
+ );
309
+ const r = d.workoutResult;
310
+ if (!r) throw new Error(`Result ${id} not found.`);
311
+
312
+ let hrMax = maxHr;
313
+ if (!hrMax) {
314
+ const u = await gql(`query{ currentUser{ maxHeartRate } }`);
315
+ hrMax = u.currentUser?.maxHeartRate || null;
316
+ }
317
+
318
+ const rows = (r.intervals || []).map((iv, i) => {
319
+ const watts = iv.avgWatts ?? wattsFromPace(iv.avgPace);
320
+ const spi = watts && iv.avgSpm ? +(watts / iv.avgSpm).toFixed(1) : null;
321
+ const pct = iv.avgHr && hrMax ? Math.round((iv.avgHr / hrMax) * 100) : null;
322
+ return {
323
+ interval: i + 1,
324
+ type: iv.type,
325
+ value: iv.value,
326
+ pace: paceString(iv.avgPace),
327
+ spm: iv.avgSpm,
328
+ watts: watts ? Math.round(watts) : null,
329
+ spi,
330
+ avgHr: iv.avgHr,
331
+ hrPct: pct,
332
+ zone: hrZone(pct),
333
+ rateConsistency: iv.rateConsistency,
334
+ };
335
+ });
336
+
337
+ return {
338
+ workout: r.workout?.title,
339
+ elapsedTime: r.elapsedTime,
340
+ elapsedDistance: r.elapsedDistance,
341
+ maxHrUsed: hrMax,
342
+ intervals: rows,
343
+ };
344
+ },
345
+ },
346
+ ];