@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 +21 -0
- package/README.md +57 -0
- package/dist/index.d.mts +198 -0
- package/dist/index.mjs +827 -0
- package/dist/library-cache-CDABOdIN.d.mts +22 -0
- package/dist/node.d.mts +14 -0
- package/dist/node.mjs +29 -0
- package/package.json +42 -0
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
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -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 };
|
package/dist/node.d.mts
ADDED
|
@@ -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
|
+
}
|