@trainheroic-unofficial/js 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alan Cohen
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,57 @@
1
+ # @trainheroic-unofficial/js
2
+
3
+ An unofficial TypeScript SDK for the TrainHeroic coaching API. It handles auth and session
4
+ renewal, talks to both TrainHeroic hosts, keeps a searchable exercise library, encodes
5
+ workouts into the API's payload format, and wraps messaging. It runs in any modern
6
+ JavaScript runtime, including Cloudflare workerd.
7
+
8
+ Part of the [trainheroic-unofficial](../../README.md) workspace.
9
+
10
+ ## Two entry points
11
+
12
+ ```ts
13
+ // Runtime-agnostic. Safe in browsers and on workerd.
14
+ import { TrainHeroicClient, ExerciseLibrary, buildSession } from "@trainheroic-unofficial/js";
15
+
16
+ // Node-only filesystem helpers, kept out of the main entry.
17
+ import { JsonFileLibraryCache, defaultCachePath } from "@trainheroic-unofficial/js/node";
18
+ ```
19
+
20
+ The `.` entry imports no `node:*` modules. Anything that touches the filesystem lives behind
21
+ `./node`, so the SDK stays portable.
22
+
23
+ ## What it covers
24
+
25
+ - **Client and auth.** `TrainHeroicClient` holds the coach credentials, acquires a session
26
+ token lazily, and renews it transparently. TrainHeroic issues no refresh token, so on a
27
+ 401/403 the client logs in again with the stored credentials and retries once. A cold
28
+ client hit by concurrent requests performs a single shared login. `RequestOptions.base`
29
+ selects the host (`coach` for `api.trainheroic.com`, `apis` for `apis.trainheroic.com`).
30
+ - **Exercises.** `ExerciseIndex` is the interface the rest of the system codes against;
31
+ `ExerciseLibrary` is the in-memory implementation that resolves names to ids, ranks
32
+ fuzzy search, and persists through a `LibraryCache` (in-memory by default, JSON file via
33
+ `./node`). The hosted server supplies a D1-backed implementation of the same interface.
34
+ - **Workouts.** A session builder (create, save blocks and exercises, optionally publish),
35
+ read-back, instruction editing, and removal, plus the encoder that turns a
36
+ `WorkoutSpec` into the API's payload.
37
+ - **Messaging.** Listing conversation streams, reading a stream, and building, sending, or
38
+ deleting a comment.
39
+
40
+ ## The workout encoder
41
+
42
+ The encoder is the package's hardest-won piece. TrainHeroic's exercise payload expects every
43
+ parameter slot present, so the encoder fills all of them (empty slots included) to avoid an
44
+ HTTP 500. A scalar prescription is broadcast across the set count, RPE is routed into the
45
+ instruction text rather than a numeric slot (the API would otherwise coerce it to load), and
46
+ unit mismatches between a spec and the exercise's fixed parameter types are surfaced as
47
+ advisories instead of silently dropped.
48
+
49
+ ## Develop
50
+
51
+ ```bash
52
+ pnpm build # tsdown -> dist (separate "." and "./node" outputs)
53
+ pnpm typecheck
54
+ pnpm test
55
+ pnpm exec vitest run test/workout-encode.test.ts # one file
56
+ pnpm exec vitest run -t "broadcasts a scalar over sets" # one test
57
+ ```
@@ -0,0 +1,198 @@
1
+ import { n as LibrarySnapshot, r as MemoryLibraryCache, t as LibraryCache } from "./library-cache-CDABOdIN.mjs";
2
+ import { Advisory, BlockSpec, ExerciseRow, ExerciseSpec, ExerciseView, ReadResult, ResolveResult, WorkoutDate } from "@trainheroic-unofficial/dto";
3
+ import { ZodType } from "zod";
4
+ export * from "@trainheroic-unofficial/dto";
5
+
6
+ //#region src/auth.d.ts
7
+ type TrainHeroicSession = {
8
+ thUserId: number;
9
+ sessionId: string;
10
+ scope: string;
11
+ role: string;
12
+ };
13
+ /**
14
+ * Authenticate against TrainHeroic. Returns the session bundle, or null on bad
15
+ * credentials. TrainHeroic returns only { id, scope, role, session_id } (verified in
16
+ * the Phase 0 spike: no refresh_token, no api_token, no TTL). The 48-char session_id
17
+ * is sent as the `session-token` header and works against both API hosts.
18
+ */
19
+ declare function loginTrainHeroic(email: string, password: string): Promise<TrainHeroicSession | null>;
20
+ //#endregion
21
+ //#region src/client.d.ts
22
+ declare class TrainHeroicAuthError extends Error {
23
+ name: string;
24
+ }
25
+ type ApiBase = "coach" | "apis";
26
+ type RequestOptions = {
27
+ body?: unknown;
28
+ base?: ApiBase;
29
+ };
30
+ type ClientResult<T = unknown> = {
31
+ status: number;
32
+ ok: boolean;
33
+ data: T;
34
+ };
35
+ /**
36
+ * Authenticated TrainHeroic API client. Holds the coach credentials (from the grant's
37
+ * encrypted props) and a lazily-acquired session token cached in memory for the life
38
+ * of the Durable Object instance. On a 401/403 it re-logs in once and retries, since
39
+ * TrainHeroic has no refresh token and sessions expire after ~1-2h.
40
+ */
41
+ declare class TrainHeroicClient {
42
+ #private;
43
+ constructor(email: string, password: string, sessionId?: string | null);
44
+ get sessionId(): string | null;
45
+ request<T = unknown>(method: string, path: string, options?: RequestOptions): Promise<ClientResult<T>>;
46
+ }
47
+ //#endregion
48
+ //#region src/response-check.d.ts
49
+ /**
50
+ * Validate an API response against its (loose) expected shape and warn once on drift.
51
+ * Never throws: callers keep working via defensive coercion. This only surfaces a signal
52
+ * when TrainHeroic renames or drops a field we read.
53
+ */
54
+ declare function checkResponse(schema: ZodType, data: unknown, label: string): void;
55
+ //#endregion
56
+ //#region src/exercise-util.d.ts
57
+ /**
58
+ * Display labels for TrainHeroic parameter types. The unit is FIXED PER EXERCISE
59
+ * (the API forces param_1_type/param_2_type back to the library default on save), so
60
+ * resolve/search surface it to stop callers picking, say, the miles "Run" for a
61
+ * metric workout. Keep in sync with the workout builder's unit table.
62
+ */
63
+ declare const PARAM_UNIT: Readonly<Record<number, string | null>>;
64
+ declare const PARAM_NONE = 0;
65
+ declare const PARAM_WEIGHT = 1;
66
+ declare const PARAM_PCT_MAX = 2;
67
+ declare const PARAM_REPS = 3;
68
+ declare const PARAM_RPE = 14;
69
+ declare function coerceInt(value: unknown): number | null;
70
+ declare function coerceNum(value: unknown): number | null;
71
+ declare function unitLabel(paramType: unknown): string | null;
72
+ /** Annotate a row with human-readable units for display. */
73
+ declare function withUnits(row: ExerciseRow): ExerciseView;
74
+ declare function buildSearchText(title: string): string;
75
+ /** Strip the {"success":1,"data":X} envelope some 2.0/coach endpoints use. */
76
+ declare function unwrapEnvelope(body: unknown): unknown;
77
+ /** Pull the exercise array out of whatever shape the bulk endpoint returns. */
78
+ declare function asExerciseList(body: unknown): Array<Record<string, unknown>>;
79
+ declare function isRecord(x: unknown): x is Record<string, unknown>;
80
+ /**
81
+ * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
82
+ * exact title, then prefix, then count of matched tokens, with shorter titles and
83
+ * standard (non-custom) exercises preferred on ties.
84
+ */
85
+ declare function rankSearch<T extends {
86
+ title: string;
87
+ can_edit: number;
88
+ }>(rows: readonly T[], query: string, limit: number): T[];
89
+ declare function chunk<T>(items: readonly T[], size: number): T[][];
90
+ /**
91
+ * The exercise-library surface the tools depend on, so the tools work over either a
92
+ * D1-backed mirror (hosted, multi-tenant) or an in-memory cache (local, single-user).
93
+ */
94
+ interface ExerciseIndex {
95
+ ensureFresh(): Promise<void>;
96
+ refresh(): Promise<Record<string, unknown>>;
97
+ resolve(name: string): Promise<ResolveResult>;
98
+ search(query: string, limit?: number): Promise<ExerciseView[]>;
99
+ get(id: number): Promise<Record<string, unknown> | null>;
100
+ defaults(id: number): Promise<{
101
+ param1: number | null;
102
+ param2: number | null;
103
+ } | null>;
104
+ create(body: Record<string, unknown>): Promise<Record<string, unknown>>;
105
+ recordDelete(id: number): Promise<void>;
106
+ stats(): Promise<Record<string, unknown>>;
107
+ }
108
+ //#endregion
109
+ //#region src/workout-encode.d.ts
110
+ declare const LEADERBOARD_TYPE: Readonly<Record<string, number>>;
111
+ declare const LEADERBOARD_LABEL: Readonly<Record<number, string>>;
112
+ type Leaderboard = {
113
+ isRedzone: number | null;
114
+ redzoneType: number;
115
+ smallerIsBetter: number | null;
116
+ redzoneInstruction: string;
117
+ };
118
+ declare function resolveLeaderboard(block: BlockSpec): Leaderboard;
119
+ declare function repsList(ex: ExerciseSpec): string[];
120
+ /** Build one saveWorkoutSetExercises payload entry with all ten param slots filled. */
121
+ declare function makeExercise(ex: ExerciseSpec, workoutSetId: number, order: number, key: string): Record<string, unknown>;
122
+ declare function buildBlockPayload(blocks: readonly BlockSpec[], workoutId: number): Array<Record<string, unknown>>;
123
+ /** Flag spec params the API will silently override to the exercise's fixed units. */
124
+ declare function unitAdvisory(blockTitle: string, ex: ExerciseSpec, defaults: {
125
+ param1: number | null;
126
+ param2: number | null;
127
+ }): Advisory;
128
+ /**
129
+ * Run unit advisories across a whole block list against an exercise index. Shared by the
130
+ * MCP workout_build tool and the CLI so both surface the same notes/warnings. Ensures the
131
+ * index is loaded first, otherwise `defaults` returns null on a cold index and every
132
+ * advisory is silently dropped.
133
+ */
134
+ declare function collectAdvisories(blocks: readonly BlockSpec[], index: ExerciseIndex): Promise<Advisory>;
135
+ //#endregion
136
+ //#region src/workout-session.d.ts
137
+ type BuildOptions = {
138
+ programId: number;
139
+ blocks: BlockSpec[];
140
+ date?: WorkoutDate;
141
+ timelineDay?: number;
142
+ publish?: boolean; /** Optional session-level note ("Coach Instructions"), set after the blocks save. */
143
+ instruction?: string;
144
+ };
145
+ declare function buildSession(client: TrainHeroicClient, opts: BuildOptions): Promise<{
146
+ pwId: number;
147
+ workoutId: number;
148
+ }>;
149
+ /**
150
+ * Set a session's Coach Instructions (the day-note at the top of a session). `pw` is the
151
+ * programWorkout object (the create-time response or a day's edit-GET entry). The PUT wants
152
+ * the whole object back with `instruction` set and `sets`/`setKeys` as a flat list of block
153
+ * ids. This does NOT change publish state: `published` is sent exactly as it is on `pw`.
154
+ */
155
+ declare function setSessionInstruction(client: TrainHeroicClient, workoutId: number, pw: Record<string, unknown>, instruction: string, blockIds: number[]): Promise<void>;
156
+ declare function removeSession(client: TrainHeroicClient, programId: number, pwId: number): Promise<void>;
157
+ declare function publishSession(client: TrainHeroicClient, pwId: number): Promise<void>;
158
+ declare function readSession(client: TrainHeroicClient, programId: number, date: WorkoutDate, pwId: number): Promise<ReadResult>;
159
+ //#endregion
160
+ //#region src/messaging.d.ts
161
+ /** Live list of chat streams, flattened to (stream, kind) tuples. */
162
+ declare function fetchStreams(client: TrainHeroicClient): Promise<Array<{
163
+ stream: Record<string, unknown>;
164
+ kind: string;
165
+ }>>;
166
+ /**
167
+ * The exact chat comment body the web app sends. The non-obvious required field is
168
+ * `feed_id` (the stream id repeated in the body); omitting it returns 400.
169
+ */
170
+ declare function buildCommentPayload(streamId: number, text: string, replyTo?: number | null): Record<string, unknown>;
171
+ declare function sendComment(client: TrainHeroicClient, streamId: number, text: string, replyTo?: number | null): Promise<Record<string, unknown>>;
172
+ declare function deleteComment(client: TrainHeroicClient, streamId: number, commentId: number): Promise<unknown>;
173
+ declare function readLive(client: TrainHeroicClient, streamId: number, limit?: number): Promise<unknown[]>;
174
+ //#endregion
175
+ //#region src/exercise-index.d.ts
176
+ /**
177
+ * The exercise library held in memory for fast queries and persisted through a
178
+ * LibraryCache (JSON file for a CLI/local server, in-memory by default). Same
179
+ * resolve/search/unit behavior as the D1-backed store, with no database.
180
+ */
181
+ declare class ExerciseLibrary implements ExerciseIndex {
182
+ #private;
183
+ constructor(client: TrainHeroicClient, cache?: LibraryCache);
184
+ ensureFresh(): Promise<void>;
185
+ refresh(): Promise<Record<string, unknown>>;
186
+ get(id: number): Promise<Record<string, unknown> | null>;
187
+ defaults(id: number): Promise<{
188
+ param1: number | null;
189
+ param2: number | null;
190
+ } | null>;
191
+ search(query: string, limit?: number): Promise<ExerciseView[]>;
192
+ resolve(name: string): Promise<ResolveResult>;
193
+ create(body: Record<string, unknown>): Promise<Record<string, unknown>>;
194
+ recordDelete(id: number): Promise<void>;
195
+ stats(): Promise<Record<string, unknown>>;
196
+ }
197
+ //#endregion
198
+ export { ApiBase, BuildOptions, ClientResult, ExerciseIndex, ExerciseLibrary, LEADERBOARD_LABEL, LEADERBOARD_TYPE, Leaderboard, LibraryCache, LibrarySnapshot, MemoryLibraryCache, PARAM_NONE, PARAM_PCT_MAX, PARAM_REPS, PARAM_RPE, PARAM_UNIT, PARAM_WEIGHT, RequestOptions, TrainHeroicAuthError, TrainHeroicClient, TrainHeroicSession, asExerciseList, buildBlockPayload, buildCommentPayload, buildSearchText, buildSession, checkResponse, chunk, coerceInt, coerceNum, collectAdvisories, deleteComment, fetchStreams, isRecord, loginTrainHeroic, makeExercise, publishSession, rankSearch, readLive, readSession, removeSession, repsList, resolveLeaderboard, sendComment, setSessionInstruction, unitAdvisory, unitLabel, unwrapEnvelope, withUnits };
package/dist/index.mjs ADDED
@@ -0,0 +1,827 @@
1
+ import { exerciseLibraryResponseSchema, exerciseResponseSchema, programsEditResponseSchema, sessionCreateResponseSchema } from "@trainheroic-unofficial/dto";
2
+ export * from "@trainheroic-unofficial/dto";
3
+ //#region src/auth.ts
4
+ const AUTH_URL = "https://apis.trainheroic.com/auth";
5
+ /**
6
+ * Authenticate against TrainHeroic. Returns the session bundle, or null on bad
7
+ * credentials. TrainHeroic returns only { id, scope, role, session_id } (verified in
8
+ * the Phase 0 spike: no refresh_token, no api_token, no TTL). The 48-char session_id
9
+ * is sent as the `session-token` header and works against both API hosts.
10
+ */
11
+ async function loginTrainHeroic(email, password) {
12
+ const res = await fetch(AUTH_URL, {
13
+ method: "POST",
14
+ headers: {
15
+ "content-type": "application/x-www-form-urlencoded",
16
+ accept: "application/json"
17
+ },
18
+ body: new URLSearchParams({
19
+ email,
20
+ password
21
+ }).toString()
22
+ });
23
+ if (!res.ok) return null;
24
+ const data = await res.json().catch(() => null);
25
+ if (!data || typeof data.id !== "number" || !data.session_id) return null;
26
+ return {
27
+ thUserId: data.id,
28
+ sessionId: data.session_id,
29
+ scope: data.scope ?? "",
30
+ role: data.role ?? ""
31
+ };
32
+ }
33
+ //#endregion
34
+ //#region src/client.ts
35
+ const COACH_BASE = "https://api.trainheroic.com";
36
+ const APIS_BASE = "https://apis.trainheroic.com";
37
+ var TrainHeroicAuthError = class extends Error {
38
+ name = "TrainHeroicAuthError";
39
+ };
40
+ /**
41
+ * Authenticated TrainHeroic API client. Holds the coach credentials (from the grant's
42
+ * encrypted props) and a lazily-acquired session token cached in memory for the life
43
+ * of the Durable Object instance. On a 401/403 it re-logs in once and retries, since
44
+ * TrainHeroic has no refresh token and sessions expire after ~1-2h.
45
+ */
46
+ var TrainHeroicClient = class {
47
+ #email;
48
+ #password;
49
+ #sessionId;
50
+ #loginInFlight = null;
51
+ constructor(email, password, sessionId = null) {
52
+ this.#email = email;
53
+ this.#password = password;
54
+ this.#sessionId = sessionId;
55
+ }
56
+ get sessionId() {
57
+ return this.#sessionId;
58
+ }
59
+ async #ensureSession() {
60
+ if (this.#sessionId) return this.#sessionId;
61
+ this.#loginInFlight ??= this.#login();
62
+ try {
63
+ return await this.#loginInFlight;
64
+ } finally {
65
+ this.#loginInFlight = null;
66
+ }
67
+ }
68
+ async #login() {
69
+ const session = await loginTrainHeroic(this.#email, this.#password);
70
+ if (!session) throw new TrainHeroicAuthError("TrainHeroic login failed");
71
+ this.#sessionId = session.sessionId;
72
+ return this.#sessionId;
73
+ }
74
+ async request(method, path, options = {}) {
75
+ const url = `${options.base === "apis" ? APIS_BASE : COACH_BASE}/${path.replace(/^\//, "")}`;
76
+ let session = await this.#ensureSession();
77
+ let res = await this.#send(method, url, session, options.body);
78
+ if (res.status === 401 || res.status === 403) {
79
+ if (this.#sessionId === session) this.#sessionId = null;
80
+ session = await this.#ensureSession();
81
+ res = await this.#send(method, url, session, options.body);
82
+ }
83
+ const text = await res.text();
84
+ let data = text;
85
+ if (text.length > 0) try {
86
+ data = JSON.parse(text);
87
+ } catch {
88
+ data = text;
89
+ }
90
+ return {
91
+ status: res.status,
92
+ ok: res.ok,
93
+ data
94
+ };
95
+ }
96
+ #send(method, url, session, body) {
97
+ const upper = method.toUpperCase();
98
+ const headers = {
99
+ accept: "application/json",
100
+ "session-token": session
101
+ };
102
+ const init = {
103
+ method: upper,
104
+ headers
105
+ };
106
+ if (body !== void 0 && upper !== "GET" && upper !== "DELETE") {
107
+ headers["content-type"] = "application/json";
108
+ init.body = JSON.stringify(body);
109
+ }
110
+ return fetch(url, init);
111
+ }
112
+ };
113
+ //#endregion
114
+ //#region src/response-check.ts
115
+ /**
116
+ * Validate an API response against its (loose) expected shape and warn once on drift.
117
+ * Never throws: callers keep working via defensive coercion. This only surfaces a signal
118
+ * when TrainHeroic renames or drops a field we read.
119
+ */
120
+ function checkResponse(schema, data, label) {
121
+ const result = schema.safeParse(data);
122
+ if (result.success) return;
123
+ const issue = result.error.issues[0];
124
+ const where = issue && issue.path.length > 0 ? issue.path.join(".") : "(root)";
125
+ console.warn(`[trainheroic] response drift in ${label} at ${where}: ${issue?.message ?? "shape mismatch"}`);
126
+ }
127
+ //#endregion
128
+ //#region src/exercise-util.ts
129
+ /**
130
+ * Display labels for TrainHeroic parameter types. The unit is FIXED PER EXERCISE
131
+ * (the API forces param_1_type/param_2_type back to the library default on save), so
132
+ * resolve/search surface it to stop callers picking, say, the miles "Run" for a
133
+ * metric workout. Keep in sync with the workout builder's unit table.
134
+ */
135
+ const PARAM_UNIT = {
136
+ 0: null,
137
+ 1: "lb",
138
+ 2: "%max",
139
+ 3: "reps",
140
+ 4: "sec",
141
+ 5: "yd",
142
+ 6: "m",
143
+ 7: "in",
144
+ 10: "mi",
145
+ 11: "ft",
146
+ 12: "in",
147
+ 13: "bpm",
148
+ 14: "RPE",
149
+ 18: "sec"
150
+ };
151
+ const PARAM_NONE = 0;
152
+ const PARAM_WEIGHT = 1;
153
+ const PARAM_PCT_MAX = 2;
154
+ const PARAM_REPS = 3;
155
+ const PARAM_RPE = 14;
156
+ function coerceInt(value) {
157
+ if (typeof value === "boolean") return value ? 1 : 0;
158
+ if (typeof value === "number") return Number.isFinite(value) ? Math.trunc(value) : null;
159
+ if (typeof value === "string" && value.trim() !== "") {
160
+ const n = Number(value);
161
+ return Number.isFinite(n) ? Math.trunc(n) : null;
162
+ }
163
+ return null;
164
+ }
165
+ function coerceNum(value) {
166
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
167
+ if (typeof value === "string" && value.trim() !== "") {
168
+ const n = Number(value);
169
+ return Number.isFinite(n) ? n : null;
170
+ }
171
+ return null;
172
+ }
173
+ function unitLabel(paramType) {
174
+ const t = coerceInt(paramType);
175
+ if (t === null) return null;
176
+ return PARAM_UNIT[t] ?? null;
177
+ }
178
+ /** Annotate a row with human-readable units for display. */
179
+ function withUnits(row) {
180
+ return {
181
+ ...row,
182
+ param_1_unit: unitLabel(row.param_1_type),
183
+ param_2_unit: unitLabel(row.param_2_type)
184
+ };
185
+ }
186
+ function buildSearchText(title) {
187
+ return title.trim().toLowerCase();
188
+ }
189
+ /** Strip the {"success":1,"data":X} envelope some 2.0/coach endpoints use. */
190
+ function unwrapEnvelope(body) {
191
+ if (body && typeof body === "object" && !Array.isArray(body)) {
192
+ const obj = body;
193
+ const keys = new Set(Object.keys(obj));
194
+ const envelope = /* @__PURE__ */ new Set([
195
+ "success",
196
+ "data",
197
+ "message",
198
+ "error"
199
+ ]);
200
+ if ("data" in obj && [...keys].every((k) => envelope.has(k))) return obj.data;
201
+ }
202
+ return body;
203
+ }
204
+ /** Pull the exercise array out of whatever shape the bulk endpoint returns. */
205
+ function asExerciseList(body) {
206
+ const unwrapped = unwrapEnvelope(body);
207
+ if (Array.isArray(unwrapped)) return unwrapped.filter((x) => isRecord(x));
208
+ if (isRecord(unwrapped)) {
209
+ const items = [];
210
+ for (const key of [
211
+ "exercises",
212
+ "circuits",
213
+ "workoutCircuits",
214
+ "library",
215
+ "items",
216
+ "results"
217
+ ]) {
218
+ const value = unwrapped[key];
219
+ if (Array.isArray(value)) items.push(...value.filter((x) => isRecord(x)));
220
+ }
221
+ if (items.length > 0) return items;
222
+ const values = Object.values(unwrapped);
223
+ if (values.length > 0 && values.every(isRecord)) return values;
224
+ }
225
+ return [];
226
+ }
227
+ function isRecord(x) {
228
+ return typeof x === "object" && x !== null && !Array.isArray(x);
229
+ }
230
+ /**
231
+ * Rank candidate rows for a free-text query (FTS5 replacement). Higher is better:
232
+ * exact title, then prefix, then count of matched tokens, with shorter titles and
233
+ * standard (non-custom) exercises preferred on ties.
234
+ */
235
+ function rankSearch(rows, query, limit) {
236
+ const q = query.trim().toLowerCase();
237
+ const tokens = q.split(/\s+/u).filter((t) => t.length > 0);
238
+ return rows.map((row) => {
239
+ const title = row.title.toLowerCase();
240
+ let score = 0;
241
+ if (title === q) score += 1e3;
242
+ if (title.startsWith(q)) score += 100;
243
+ for (const tok of tokens) if (title.includes(tok)) score += 10;
244
+ score -= title.length * .05;
245
+ if (row.can_edit === 0) score += 1;
246
+ return {
247
+ row,
248
+ score
249
+ };
250
+ }).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.row);
251
+ }
252
+ function chunk(items, size) {
253
+ const out = [];
254
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
255
+ return out;
256
+ }
257
+ //#endregion
258
+ //#region src/workout-encode.ts
259
+ const LEADERBOARD_TYPE = {
260
+ completion: 0,
261
+ "for completion": 0,
262
+ weight: 1,
263
+ lb: 1,
264
+ load: 1,
265
+ reps: 2,
266
+ rep: 2,
267
+ rounds: 3,
268
+ round: 3,
269
+ time: 4,
270
+ yards: 5,
271
+ yd: 5,
272
+ meters: 6,
273
+ m: 6,
274
+ feet: 7,
275
+ ft: 7,
276
+ calories: 8,
277
+ cal: 8,
278
+ cals: 8,
279
+ miles: 10,
280
+ mi: 10,
281
+ inches: 12,
282
+ in: 12,
283
+ watts: 15,
284
+ w: 15,
285
+ velocity: 17,
286
+ "m/s": 17,
287
+ seconds: 18,
288
+ sec: 18,
289
+ s: 18
290
+ };
291
+ const LEADERBOARD_LABEL = {
292
+ 0: "For Completion",
293
+ 1: "Weight",
294
+ 2: "Reps",
295
+ 3: "Rounds",
296
+ 4: "Time",
297
+ 5: "Yards",
298
+ 6: "Meters",
299
+ 7: "Feet",
300
+ 8: "Calories",
301
+ 10: "Miles",
302
+ 12: "Inches",
303
+ 13: "Other",
304
+ 15: "Watts",
305
+ 16: "Percent",
306
+ 17: "Velocity",
307
+ 18: "Seconds"
308
+ };
309
+ function resolveLeaderboard(block) {
310
+ const lb = block.leaderboard;
311
+ if (lb === void 0 || lb === null) return {
312
+ isRedzone: null,
313
+ redzoneType: 0,
314
+ smallerIsBetter: null,
315
+ redzoneInstruction: ""
316
+ };
317
+ let unit;
318
+ let instruction = "";
319
+ let lowest;
320
+ if (typeof lb === "object") {
321
+ unit = lb.unit ?? lb.type;
322
+ instruction = lb.instruction ?? "";
323
+ lowest = lb.lowest_wins;
324
+ } else unit = lb;
325
+ let rz;
326
+ if (typeof unit === "string") {
327
+ const found = LEADERBOARD_TYPE[unit.trim().toLowerCase()];
328
+ if (found === void 0) throw new Error(`Unknown leaderboard unit '${unit}'. Use one of: ${Object.keys(LEADERBOARD_TYPE).join(", ")}.`);
329
+ rz = found;
330
+ } else if (typeof unit === "number") rz = Math.trunc(unit);
331
+ else throw new Error("Leaderboard requires a unit.");
332
+ if (lowest === void 0) lowest = rz === 4 || rz === 18;
333
+ return {
334
+ isRedzone: 1,
335
+ redzoneType: rz,
336
+ smallerIsBetter: lowest ? 1 : 0,
337
+ redzoneInstruction: instruction
338
+ };
339
+ }
340
+ function slots(values, n = 10) {
341
+ const out = [];
342
+ for (let i = 0; i < n; i += 1) out.push(values && i < values.length ? values[i] ?? "" : "");
343
+ return out;
344
+ }
345
+ function repsList(ex) {
346
+ const reps = ex.reps;
347
+ if (Array.isArray(reps)) return reps.map((r) => String(r));
348
+ if (reps === void 0 || reps === null) return [];
349
+ const sets = Math.max(1, Math.trunc(Number(ex.sets ?? 1)) || 1);
350
+ return Array.from({ length: sets }, () => String(reps));
351
+ }
352
+ /** Build one saveWorkoutSetExercises payload entry with all ten param slots filled. */
353
+ function makeExercise(ex, workoutSetId, order, key) {
354
+ const reps = repsList(ex);
355
+ let instruction = ex.instr ?? "";
356
+ if (instruction === "" && ex.rpe !== void 0 && ex.rpe !== null) instruction = `RPE ${ex.rpe}`;
357
+ const hasWeight = ex.weight !== void 0 && ex.weight !== null;
358
+ const weightArr = Array.isArray(ex.weight) ? ex.weight : null;
359
+ let count = reps.length;
360
+ if (count === 0 && hasWeight) count = weightArr ? weightArr.length : Math.max(1, Math.trunc(Number(ex.sets ?? 1)) || 1);
361
+ let param2Type;
362
+ let param2Values;
363
+ if (hasWeight) {
364
+ param2Type = ex.param_2_type ?? 1;
365
+ param2Values = (weightArr ?? Array.from({ length: count }, () => ex.weight)).map((v) => String(v));
366
+ } else {
367
+ param2Type = 0;
368
+ param2Values = null;
369
+ }
370
+ const entry = {
371
+ exercise_id: ex.id,
372
+ workout_set_id: workoutSetId,
373
+ set_id: workoutSetId,
374
+ setKey: workoutSetId,
375
+ title: ex.title ?? "",
376
+ instruction,
377
+ order,
378
+ param_1_type: ex.param_1_type ?? 3,
379
+ param_2_type: param2Type,
380
+ workout_set_exercise_template_id: null,
381
+ no_sets: 0,
382
+ param_count: count,
383
+ set_num: count,
384
+ key,
385
+ video_url: "",
386
+ thumbnail_url: "",
387
+ tags: [],
388
+ eType: "e",
389
+ use_count: 0
390
+ };
391
+ const p1 = slots(reps);
392
+ const p2 = slots(param2Values);
393
+ for (let i = 0; i < 10; i += 1) {
394
+ entry[`param_1_data_${i + 1}`] = p1[i] ?? "";
395
+ entry[`param_2_data_${i + 1}`] = p2[i] ?? "";
396
+ }
397
+ return entry;
398
+ }
399
+ function buildBlockPayload(blocks, workoutId) {
400
+ return blocks.map((b, i) => {
401
+ const lb = resolveLeaderboard(b);
402
+ return {
403
+ workout_id: workoutId,
404
+ order: i + 1,
405
+ type: b.type ?? 2,
406
+ instruction: b.instruction ?? "",
407
+ is_redzone: lb.isRedzone,
408
+ redzone_type: lb.redzoneType,
409
+ smaller_is_better: lb.smallerIsBetter,
410
+ redzone_instruction: lb.redzoneInstruction,
411
+ exercises: [],
412
+ exerciseKeys: [],
413
+ key: `k::${workoutId}${i + 1}`,
414
+ title: b.title
415
+ };
416
+ });
417
+ }
418
+ function unitOr(t) {
419
+ return unitLabel(t) ?? "?";
420
+ }
421
+ /** Flag spec params the API will silently override to the exercise's fixed units. */
422
+ function unitAdvisory(blockTitle, ex, defaults) {
423
+ const notes = [];
424
+ const warnings = [];
425
+ const u = unitOr;
426
+ const label = `${blockTitle} / ${ex.title ?? ex.id}`;
427
+ if (Array.isArray(ex.reps) && ex.sets !== void 0 && ex.reps.length !== ex.sets) warnings.push(`${label}: reps array has ${ex.reps.length} entr${ex.reps.length === 1 ? "y" : "ies"}; sets:${ex.sets} is ignored — building ${ex.reps.length} set(s).`);
428
+ const sentP1 = ex.param_1_type;
429
+ if (sentP1 !== void 0 && sentP1 !== null && Math.trunc(Number(sentP1)) !== defaults.param1) {
430
+ const sp1 = Math.trunc(Number(sentP1));
431
+ warnings.push(`${label}: param_1_type ${sentP1} (${u(sp1)}) is ignored — this exercise is fixed to ${u(defaults.param1)}; values render as ${u(defaults.param1)}.`);
432
+ } else if (defaults.param1 !== 3 && defaults.param1 !== null) notes.push(`${label}: values are in ${u(defaults.param1)} (the exercise's fixed primary unit).`);
433
+ if (ex.weight !== void 0 && ex.weight !== null) {
434
+ const sentP2 = Math.trunc(Number(ex.param_2_type ?? 1));
435
+ const effP2 = defaults.param2 === 0 || defaults.param2 === null ? 1 : defaults.param2;
436
+ if (sentP2 !== effP2) if (sentP2 === 2 || sentP2 === 14) warnings.push(`${label}: ${u(sentP2)} does not stick on this exercise — it renders as ${u(effP2)}. Put it in the exercise 'instr' text and leave load blank.`);
437
+ else warnings.push(`${label}: load renders as ${u(effP2)}, not ${u(sentP2)} (this exercise's secondary unit is fixed).`);
438
+ }
439
+ return {
440
+ notes,
441
+ warnings
442
+ };
443
+ }
444
+ /**
445
+ * Run unit advisories across a whole block list against an exercise index. Shared by the
446
+ * MCP workout_build tool and the CLI so both surface the same notes/warnings. Ensures the
447
+ * index is loaded first, otherwise `defaults` returns null on a cold index and every
448
+ * advisory is silently dropped.
449
+ */
450
+ async function collectAdvisories(blocks, index) {
451
+ await index.ensureFresh();
452
+ const pairs = blocks.flatMap((b) => b.exercises.map((ex) => ({
453
+ block: b,
454
+ ex
455
+ })));
456
+ const defaults = await Promise.all(pairs.map((p) => {
457
+ const id = Number(p.ex.id);
458
+ return Number.isFinite(id) ? index.defaults(id) : Promise.resolve(null);
459
+ }));
460
+ const notes = [];
461
+ const warnings = [];
462
+ pairs.forEach((p, i) => {
463
+ const def = defaults[i];
464
+ if (!def) return;
465
+ const advisory = unitAdvisory(p.block.title, p.ex, def);
466
+ notes.push(...advisory.notes);
467
+ warnings.push(...advisory.warnings);
468
+ });
469
+ return {
470
+ notes,
471
+ warnings
472
+ };
473
+ }
474
+ //#endregion
475
+ //#region src/workout-session.ts
476
+ async function req(client, method, path, body) {
477
+ const res = await client.request(method, path, body === void 0 ? void 0 : { body });
478
+ if (!res.ok) {
479
+ const detail = typeof res.data === "string" ? res.data : JSON.stringify(res.data);
480
+ throw new Error(`${method} ${path} failed (HTTP ${res.status}): ${detail}`);
481
+ }
482
+ return res.data;
483
+ }
484
+ function createPath(opts) {
485
+ if (opts.timelineDay !== void 0) return `/2.0/coach/calendar/workout/createWorkoutForTimelineDay/${opts.programId}/${opts.timelineDay}/null`;
486
+ if (!opts.date) throw new Error("workout build requires either date or timelineDay");
487
+ const [y, m, d] = opts.date;
488
+ return `/2.0/coach/calendar/workout/createWorkoutForDay/${opts.programId}/${y}/${m}/${d}/0`;
489
+ }
490
+ async function buildSession(client, opts) {
491
+ const sess = await req(client, "POST", createPath(opts), {});
492
+ checkResponse(sessionCreateResponseSchema, sess, "session create");
493
+ const workoutId = Number(sess.workout_id);
494
+ const pwId = Number(sess.id);
495
+ const created = await req(client, "POST", "/2.0/coach/calendar/saveProgramWorkoutSets", buildBlockPayload(opts.blocks, workoutId));
496
+ const byOrder = new Map(created.map((b) => [b.order, b.id]));
497
+ let counter = 0;
498
+ const payloads = opts.blocks.map((block, i) => {
499
+ const wsid = byOrder.get(i + 1);
500
+ if (wsid === void 0) throw new Error(`No saved block for order ${i + 1}.`);
501
+ return block.exercises.map((ex, j) => {
502
+ counter += 1;
503
+ return makeExercise(ex, wsid, j + 1, `k::${workoutId}${String(counter).padStart(3, "0")}`);
504
+ });
505
+ });
506
+ await Promise.all(payloads.map((p) => req(client, "POST", "/2.0/coach/calendar/saveWorkoutSetExercises", p)));
507
+ if (opts.instruction !== void 0 && opts.instruction !== "") {
508
+ const blockIds = [...byOrder.entries()].sort((a, b) => a[0] - b[0]).map(([, id]) => id);
509
+ await setSessionInstruction(client, workoutId, sess, opts.instruction, blockIds);
510
+ }
511
+ if (opts.publish ?? false) await req(client, "POST", "/2.0/coach/calendar/programWorkout/publish", [pwId]);
512
+ return {
513
+ pwId,
514
+ workoutId
515
+ };
516
+ }
517
+ /**
518
+ * Set a session's Coach Instructions (the day-note at the top of a session). `pw` is the
519
+ * programWorkout object (the create-time response or a day's edit-GET entry). The PUT wants
520
+ * the whole object back with `instruction` set and `sets`/`setKeys` as a flat list of block
521
+ * ids. This does NOT change publish state: `published` is sent exactly as it is on `pw`.
522
+ */
523
+ async function setSessionInstruction(client, workoutId, pw, instruction, blockIds) {
524
+ const body = {
525
+ ...pw,
526
+ instruction,
527
+ sets: blockIds,
528
+ setKeys: blockIds
529
+ };
530
+ await req(client, "PUT", `/3.0/coach/workout/${workoutId}`, body);
531
+ }
532
+ async function removeSession(client, programId, pwId) {
533
+ await req(client, "POST", "/2.0/coach/calendar/removeProgramWorkout", {
534
+ programId,
535
+ pwId
536
+ });
537
+ }
538
+ async function publishSession(client, pwId) {
539
+ await req(client, "POST", "/2.0/coach/calendar/programWorkout/publish", [pwId]);
540
+ }
541
+ function str(value) {
542
+ return value === void 0 || value === null ? "" : String(value);
543
+ }
544
+ async function readSession(client, programId, date, pwId) {
545
+ const [y, m, d] = date;
546
+ const data = await req(client, "GET", `/1.0/coach/programs/edit/${programId}/${y}/${m}/${d}`);
547
+ checkResponse(programsEditResponseSchema, data, "programs edit");
548
+ const pw = (data.programWorkouts ?? []).find((p) => coerceInt(p.id) === pwId);
549
+ if (!pw) throw new Error(`programWorkout ${pwId} not found on ${y}-${m}-${d}.`);
550
+ const setsObj = pw.sets ?? {};
551
+ const blocks = Object.values(setsObj).sort((a, b) => Number(a.order) - Number(b.order)).map((b) => readBlock(b));
552
+ return {
553
+ pwId,
554
+ date: `${str(pw.year)}-${str(pw.month)}-${str(pw.day)}`,
555
+ published: pw.published,
556
+ instruction: str(pw.instruction),
557
+ blocks
558
+ };
559
+ }
560
+ function readBlock(b) {
561
+ const rz = coerceInt(b.redzone_type);
562
+ let leaderboard = null;
563
+ if (rz && rz > 0) leaderboard = `FOR ${(LEADERBOARD_LABEL[rz] ?? `type ${rz}`).toUpperCase()}${b.smaller_is_better ? " (lowest wins)" : ""}`;
564
+ const exercises = (Array.isArray(b.exercises) ? b.exercises : []).sort((a, e) => Number(a.order) - Number(e.order)).map((ex) => readExercise(ex));
565
+ return {
566
+ order: Number(b.order),
567
+ title: str(b.title),
568
+ leaderboard,
569
+ exercises
570
+ };
571
+ }
572
+ function readExercise(ex) {
573
+ const reps = [];
574
+ const load = [];
575
+ for (let i = 1; i <= 10; i += 1) {
576
+ const r = str(ex[`param_1_data_${i}`]);
577
+ if (r !== "") reps.push(r);
578
+ const w = str(ex[`param_2_data_${i}`]);
579
+ if (w !== "") load.push(w);
580
+ }
581
+ return {
582
+ order: Number(ex.order),
583
+ title: str(ex.title),
584
+ reps,
585
+ primaryUnit: unitLabel(coerceInt(ex.param_1_type)),
586
+ load,
587
+ loadUnit: unitLabel(coerceInt(ex.param_2_type)),
588
+ instruction: str(ex.instruction)
589
+ };
590
+ }
591
+ //#endregion
592
+ //#region src/messaging.ts
593
+ const BUCKETS = [
594
+ ["team", "teams"],
595
+ ["athlete", "athletes"],
596
+ ["program", "programs"],
597
+ ["coach", "coaches"]
598
+ ];
599
+ /** Live list of chat streams, flattened to (stream, kind) tuples. */
600
+ async function fetchStreams(client) {
601
+ const res = await client.request("GET", "/v5/messaging/streams");
602
+ if (!res.ok || !isRecord(res.data)) throw new Error(`GET /v5/messaging/streams failed (HTTP ${res.status}).`);
603
+ const out = [];
604
+ for (const [kind, key] of BUCKETS) {
605
+ const bucket = res.data[key];
606
+ if (Array.isArray(bucket)) {
607
+ for (const s of bucket) if (isRecord(s) && coerceInt(s.id) !== null) out.push({
608
+ stream: s,
609
+ kind
610
+ });
611
+ }
612
+ }
613
+ return out;
614
+ }
615
+ /**
616
+ * The exact chat comment body the web app sends. The non-obvious required field is
617
+ * `feed_id` (the stream id repeated in the body); omitting it returns 400.
618
+ */
619
+ function buildCommentPayload(streamId, text, replyTo = null) {
620
+ return {
621
+ type: 0,
622
+ content: text,
623
+ photo_url: "",
624
+ photoUrl: "",
625
+ access_level: 0,
626
+ parent_feed_item_id: replyTo,
627
+ feed_id: streamId
628
+ };
629
+ }
630
+ async function sendComment(client, streamId, text, replyTo = null) {
631
+ const res = await client.request("POST", `/v5/messaging/streams/${streamId}/comments`, { body: buildCommentPayload(streamId, text, replyTo) });
632
+ if (!res.ok || typeof res.data !== "object" || res.data === null || res.data.id === void 0) throw new Error(`Message send failed (HTTP ${res.status}).`);
633
+ return res.data;
634
+ }
635
+ async function deleteComment(client, streamId, commentId) {
636
+ const res = await client.request("DELETE", `/v5/messaging/streams/${streamId}/comments/${commentId}`);
637
+ if (!res.ok) throw new Error(`Message delete failed (HTTP ${res.status}).`);
638
+ return res.data;
639
+ }
640
+ async function readLive(client, streamId, limit) {
641
+ const res = await client.request("GET", `/v5/messaging/streams/${streamId}/comments?lastCommentId=`);
642
+ if (!res.ok || !Array.isArray(res.data)) throw new Error(`Message read failed (HTTP ${res.status}).`);
643
+ return limit !== void 0 && limit > 0 ? res.data.slice(-limit) : res.data;
644
+ }
645
+ //#endregion
646
+ //#region src/library-cache.ts
647
+ /** In-memory cache (no persistence). The default when none is supplied. */
648
+ var MemoryLibraryCache = class {
649
+ #snapshot = null;
650
+ async load() {
651
+ return this.#snapshot;
652
+ }
653
+ async save(snapshot) {
654
+ this.#snapshot = snapshot;
655
+ }
656
+ };
657
+ //#endregion
658
+ //#region src/exercise-index.ts
659
+ const LIBRARY_PATH = "/v5/exerciseLibrary/all";
660
+ const CREATE_PATH = "/2.0/coach/exercise/create";
661
+ const TTL_MS = 168 * 3600 * 1e3;
662
+ function toStored(ex) {
663
+ const id = coerceInt(ex.id);
664
+ if (id === null) return null;
665
+ const title = String(ex.title ?? "");
666
+ return {
667
+ id,
668
+ title,
669
+ search: buildSearchText(title),
670
+ param_1_type: coerceInt(ex.param_1_type),
671
+ param_2_type: coerceInt(ex.param_2_type),
672
+ can_edit: coerceInt(ex.can_edit) ?? 0,
673
+ user_id: coerceInt(ex.user_id),
674
+ use_count: coerceInt(ex.use_count) ?? 0,
675
+ raw: ex
676
+ };
677
+ }
678
+ function toRow(s) {
679
+ return {
680
+ id: s.id,
681
+ title: s.title,
682
+ param_1_type: s.param_1_type,
683
+ param_2_type: s.param_2_type,
684
+ can_edit: s.can_edit,
685
+ user_id: s.user_id,
686
+ use_count: s.use_count
687
+ };
688
+ }
689
+ /**
690
+ * The exercise library held in memory for fast queries and persisted through a
691
+ * LibraryCache (JSON file for a CLI/local server, in-memory by default). Same
692
+ * resolve/search/unit behavior as the D1-backed store, with no database.
693
+ */
694
+ var ExerciseLibrary = class {
695
+ #client;
696
+ #cache;
697
+ #byId = /* @__PURE__ */ new Map();
698
+ #loaded = false;
699
+ #fetchedAt = 0;
700
+ constructor(client, cache = new MemoryLibraryCache()) {
701
+ this.#client = client;
702
+ this.#cache = cache;
703
+ }
704
+ #hydrate(list, fetchedAt) {
705
+ const next = /* @__PURE__ */ new Map();
706
+ for (const ex of list) {
707
+ const s = toStored(ex);
708
+ if (s) next.set(s.id, s);
709
+ }
710
+ this.#byId = next;
711
+ this.#fetchedAt = fetchedAt;
712
+ this.#loaded = true;
713
+ }
714
+ async #ensureLoaded() {
715
+ if (this.#loaded) return;
716
+ const snap = await this.#cache.load();
717
+ if (snap && snap.exercises.length > 0 && Date.now() - snap.fetchedAt <= TTL_MS) this.#hydrate(snap.exercises, snap.fetchedAt);
718
+ else await this.refresh();
719
+ }
720
+ async #persist() {
721
+ await this.#cache.save({
722
+ fetchedAt: this.#fetchedAt,
723
+ exercises: [...this.#byId.values()].map((s) => s.raw)
724
+ });
725
+ }
726
+ async ensureFresh() {
727
+ if (!this.#loaded) await this.#ensureLoaded();
728
+ else if (Date.now() - this.#fetchedAt > TTL_MS) await this.refresh();
729
+ }
730
+ async refresh() {
731
+ const res = await this.#client.request("GET", LIBRARY_PATH);
732
+ if (!res.ok) throw new Error(`Exercise library fetch failed (HTTP ${res.status}).`);
733
+ const list = asExerciseList(res.data);
734
+ if (list.length === 0) throw new Error("Exercise library returned no rows; keeping the cache.");
735
+ checkResponse(exerciseLibraryResponseSchema, list, "exercise library");
736
+ this.#hydrate(list, Date.now());
737
+ await this.#persist();
738
+ return { synced: this.#byId.size };
739
+ }
740
+ async get(id) {
741
+ await this.ensureFresh();
742
+ const s = this.#byId.get(id);
743
+ if (!s) return null;
744
+ const full = { ...s.raw };
745
+ full.param_1_unit = unitLabel(full.param_1_type);
746
+ full.param_2_unit = unitLabel(full.param_2_type);
747
+ return full;
748
+ }
749
+ async defaults(id) {
750
+ const s = this.#byId.get(id);
751
+ return s ? {
752
+ param1: s.param_1_type,
753
+ param2: s.param_2_type
754
+ } : null;
755
+ }
756
+ async search(query, limit = 20) {
757
+ await this.ensureFresh();
758
+ return this.#searchOnly(query, limit);
759
+ }
760
+ #searchOnly(query, limit) {
761
+ const tokens = query.toLowerCase().split(/\s+/u).filter((t) => t.length > 0);
762
+ if (tokens.length === 0) return [];
763
+ return rankSearch([...this.#byId.values()].filter((s) => tokens.every((t) => s.search.includes(t))).map((s) => toRow(s)), query, limit).map(withUnits);
764
+ }
765
+ #exact(name) {
766
+ const q = name.trim().toLowerCase();
767
+ const first = [...this.#byId.values()].filter((s) => s.search === q).sort((a, b) => a.can_edit - b.can_edit)[0];
768
+ return first ? withUnits(toRow(first)) : null;
769
+ }
770
+ async resolve(name) {
771
+ await this.ensureFresh();
772
+ let hit = this.#exact(name);
773
+ if (hit) return {
774
+ match: hit,
775
+ candidates: [hit]
776
+ };
777
+ let candidates = this.#searchOnly(name, 20);
778
+ if (candidates.length === 0) {
779
+ await this.refresh();
780
+ hit = this.#exact(name);
781
+ if (hit) return {
782
+ match: hit,
783
+ candidates: [hit]
784
+ };
785
+ candidates = this.#searchOnly(name, 20);
786
+ }
787
+ if (candidates.length === 1) return {
788
+ match: candidates[0] ?? null,
789
+ candidates
790
+ };
791
+ return {
792
+ match: null,
793
+ candidates
794
+ };
795
+ }
796
+ async create(body) {
797
+ await this.ensureFresh();
798
+ const res = await this.#client.request("POST", CREATE_PATH, { body });
799
+ if (!res.ok) throw new Error(`Exercise create failed (HTTP ${res.status}).`);
800
+ const ex = unwrapEnvelope(res.data);
801
+ if (ex && typeof ex === "object") {
802
+ checkResponse(exerciseResponseSchema, ex, "exercise create");
803
+ const s = toStored(ex);
804
+ if (s) {
805
+ this.#byId.set(s.id, s);
806
+ await this.#persist();
807
+ }
808
+ }
809
+ return ex;
810
+ }
811
+ async recordDelete(id) {
812
+ await this.ensureFresh();
813
+ if (this.#byId.delete(id)) await this.#persist();
814
+ }
815
+ async stats() {
816
+ let custom = 0;
817
+ for (const s of this.#byId.values()) if (s.can_edit === 1) custom += 1;
818
+ return {
819
+ exercises: this.#byId.size,
820
+ custom,
821
+ loaded: this.#loaded,
822
+ fetchedAt: this.#fetchedAt
823
+ };
824
+ }
825
+ };
826
+ //#endregion
827
+ export { ExerciseLibrary, LEADERBOARD_LABEL, LEADERBOARD_TYPE, MemoryLibraryCache, PARAM_NONE, PARAM_PCT_MAX, PARAM_REPS, PARAM_RPE, PARAM_UNIT, PARAM_WEIGHT, TrainHeroicAuthError, TrainHeroicClient, asExerciseList, buildBlockPayload, buildCommentPayload, buildSearchText, buildSession, checkResponse, chunk, coerceInt, coerceNum, collectAdvisories, deleteComment, fetchStreams, isRecord, loginTrainHeroic, makeExercise, publishSession, rankSearch, readLive, readSession, removeSession, repsList, resolveLeaderboard, sendComment, setSessionInstruction, unitAdvisory, unitLabel, unwrapEnvelope, withUnits };
@@ -0,0 +1,22 @@
1
+ //#region src/library-cache.d.ts
2
+ /** A persisted snapshot of the exercise library: the raw rows plus when they were fetched. */
3
+ type LibrarySnapshot = {
4
+ fetchedAt: number;
5
+ exercises: Array<Record<string, unknown>>;
6
+ };
7
+ /**
8
+ * Where ExerciseLibrary persists the library between runs. Abstracted so the library
9
+ * logic stays runtime-agnostic; the Node JSON-file backend is in the "./node" subpath.
10
+ */
11
+ interface LibraryCache {
12
+ load(): Promise<LibrarySnapshot | null>;
13
+ save(snapshot: LibrarySnapshot): Promise<void>;
14
+ }
15
+ /** In-memory cache (no persistence). The default when none is supplied. */
16
+ declare class MemoryLibraryCache implements LibraryCache {
17
+ #private;
18
+ load(): Promise<LibrarySnapshot | null>;
19
+ save(snapshot: LibrarySnapshot): Promise<void>;
20
+ }
21
+ //#endregion
22
+ export { LibrarySnapshot as n, MemoryLibraryCache as r, LibraryCache as t };
@@ -0,0 +1,14 @@
1
+ import { n as LibrarySnapshot, t as LibraryCache } from "./library-cache-CDABOdIN.mjs";
2
+
3
+ //#region src/node.d.ts
4
+ /** Default exercise-cache path, overridable with TRAINHEROIC_CACHE_FILE. */
5
+ declare function defaultCachePath(): string;
6
+ /** Persists the exercise library to a JSON file. */
7
+ declare class JsonFileLibraryCache implements LibraryCache {
8
+ #private;
9
+ constructor(path?: string);
10
+ load(): Promise<LibrarySnapshot | null>;
11
+ save(snapshot: LibrarySnapshot): Promise<void>;
12
+ }
13
+ //#endregion
14
+ export { JsonFileLibraryCache, defaultCachePath };
package/dist/node.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import process from "node:process";
5
+ //#region src/node.ts
6
+ /** Default exercise-cache path, overridable with TRAINHEROIC_CACHE_FILE. */
7
+ function defaultCachePath() {
8
+ return process.env.TRAINHEROIC_CACHE_FILE ?? join(homedir(), ".trainheroic", "library.json");
9
+ }
10
+ /** Persists the exercise library to a JSON file. */
11
+ var JsonFileLibraryCache = class {
12
+ #path;
13
+ constructor(path = defaultCachePath()) {
14
+ this.#path = path;
15
+ }
16
+ async load() {
17
+ try {
18
+ const parsed = JSON.parse(await readFile(this.#path, "utf8"));
19
+ if (typeof parsed.fetchedAt === "number" && Array.isArray(parsed.exercises)) return parsed;
20
+ } catch {}
21
+ return null;
22
+ }
23
+ async save(snapshot) {
24
+ await mkdir(dirname(this.#path), { recursive: true });
25
+ await writeFile(this.#path, JSON.stringify(snapshot), { mode: 384 });
26
+ }
27
+ };
28
+ //#endregion
29
+ export { JsonFileLibraryCache, defaultCachePath };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@trainheroic-unofficial/js",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/alandotcom/trainheroic-skill.git",
8
+ "directory": "packages/js"
9
+ },
10
+ "description": "Unofficial TypeScript SDK for the TrainHeroic coaching API.",
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.mts",
15
+ "import": "./dist/index.mjs"
16
+ },
17
+ "./node": {
18
+ "types": "./dist/node.d.mts",
19
+ "import": "./dist/node.mjs"
20
+ }
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "dependencies": {
29
+ "zod": "^4.4.3",
30
+ "@trainheroic-unofficial/dto": "0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^26.0.0",
34
+ "tsdown": "^0.22.3",
35
+ "vitest": "^4.1.9"
36
+ },
37
+ "scripts": {
38
+ "build": "tsdown",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run"
41
+ }
42
+ }