@trainheroic-unofficial/cli 0.1.0 → 0.2.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.
@@ -0,0 +1,127 @@
1
+ # Warehouse store (design reference)
2
+
3
+ > **Note.** This documents the warehouse _design_ — the exercise mirror plus the
4
+ > programming/messaging history zones, the prune-vs-accumulate rule, `source`
5
+ > provenance, and the per-calendar month-window walk. That warehouse now lives in the
6
+ > hosted MCP server (`@trainheroic-unofficial/cloudflare`, D1). The CLI and the local
7
+ > server cache only the exercise library, to JSON at `~/.trainheroic/library.json`.
8
+ > The SQLite / `library_cache.py` / `*_sync.py` names below are historical; the schema
9
+ > and sync rules still apply to the hosted zones.
10
+
11
+ `library_cache.py` manages a SQLite database at `~/.trainheroic/library.db`
12
+ (mode 0600, outside any repo). It is both a read-through cache for exercise
13
+ lookups and a durable local store any future tool can open and query directly.
14
+ Stdlib `sqlite3` only — no dependencies.
15
+
16
+ Import it as a module (`from library_cache import ExerciseCache`) or use the CLI:
17
+
18
+ ```bash
19
+ library_cache.py resolve "<name>" # name -> exercise (exit 3 + candidates if ambiguous)
20
+ library_cache.py search "<query>" # ranked FTS search
21
+ library_cache.py get <id> # one exercise as JSON
22
+ library_cache.py sync [--force] # refresh the reference mirror from the API
23
+ library_cache.py stats # row counts per zone + cursors
24
+ library_cache.py cursors # the sync_state watermark table
25
+ library_cache.py create '<json>' # create a custom exercise (API) + write-through
26
+ library_cache.py forget <id> # drop an exercise from the mirror (cache-only)
27
+ ```
28
+
29
+ Programming history and messages are synced by separate scripts:
30
+
31
+ ```bash
32
+ programming_sync.py # pull every calendar into the programming zone
33
+ programming_sync.py <cal_id> # one calendar/program id
34
+ messaging_sync.py # pull every chat stream into the messaging zone
35
+ messaging_sync.py --full # re-pull every stream from the beginning
36
+ messaging_sync.py <stream_id> # one stream id
37
+ ```
38
+
39
+ ## Zones
40
+
41
+ The DB holds three zones that follow deliberately different rules. The split is
42
+ load-bearing: applying the reference zone's prune logic to records would silently
43
+ delete history on a short fetch window.
44
+
45
+ ### Reference zone — implemented
46
+
47
+ `exercise`, `tag`, `exercise_tag`, `swap`, `exercise_fts` (FTS5 over titles).
48
+
49
+ - Sourced from `GET /v5/exerciseLibrary/all` plus the tag endpoints.
50
+ - **Read-through with a 7-day TTL.** `ensure_fresh()` backfills when the DB is
51
+ empty or stale; a `resolve` miss forces one refresh and retries (catches
52
+ exercises added through the web UI out of band).
53
+ - **Prune-to-match.** Each full sync bumps a generation counter; rows the API no
54
+ longer returns are deleted — but only when the response has at least
55
+ `PRUNE_FLOOR` (100) rows, so a thin/garbled response never wipes the mirror.
56
+ - **Write-through hooks** `record_upsert(ex)` / `record_delete(id)` keep the
57
+ mirror correct after a create/update/delete without a full re-sync.
58
+ - IDs follow the API's own integers (Back Squat = 1, Bench Press = 1162);
59
+ id-less entries are skipped (this is the ~2384 → 2371 gap).
60
+
61
+ ### Programming zone — implemented
62
+
63
+ `program`, `program_session`, `block`, `prescribed_set`, populated by
64
+ `programming_sync.py`.
65
+
66
+ - **Calendars to pull** = the union of `/1.0/coach/programs` (standalone) and each
67
+ team's `group_program` from `/1.0/coach/teams`.
68
+ - **Sourced from `GET /1.0/coach/programs/edit/{cal}/{y}/{m}/1`**, walked month by
69
+ month across a window (`MONTHS_BACK`/`MONTHS_FWD`): that endpoint returns only
70
+ the queried month's sessions, not the whole calendar.
71
+ - **Accumulate-only** — never pruned, so it retains history even after a session
72
+ is deleted on TrainHeroic. Each sync upserts every session and rebuilds that
73
+ session's own blocks/sets (delete + re-insert) to absorb edits, then advances
74
+ `sync_state('programming', <cal_id>)`. Re-running is idempotent.
75
+ - `prescribed_set` holds one row per prescribed set (the API's `param_N_data_1..10`
76
+ slots expanded). `prescribed_set.exercise_id` is a **soft** join to
77
+ `exercise(id)` — not an enforced FK, since the library cache may lag custom
78
+ exercises and a sync must not fail on a cache miss.
79
+
80
+ ### Messaging zone — implemented
81
+
82
+ `message_stream`, `message_comment`, populated by `messaging_sync.py`.
83
+
84
+ - **Conversations** from `GET /v5/messaging/streams` (buckets `teams`, `athletes`,
85
+ `programs`, `coaches`). Each entry's `id` is the **stream id** — distinct from
86
+ `teamId`/`userId` and what every other messaging call keys on; `message_stream`
87
+ records `kind`, `team_id`, `user_id`, `last_viewed`.
88
+ - **Messages** from `GET /v5/messaging/streams/{id}/comments?lastCommentId={cursor}`,
89
+ walked incrementally: the cursor is the highest comment id stored, written to
90
+ `sync_state('messaging', stream_id)`, so a normal run only pulls newer comments.
91
+ `--full` passes a blank cursor to re-pull a whole stream (the only way to refresh
92
+ reactions/replies on already-synced comments, since the incremental call returns
93
+ newer-than-cursor only).
94
+ - **Accumulate-only** — comments are upserted by id and never pruned, so a message
95
+ soft-deleted on TrainHeroic is retained here as history (same rule as programming).
96
+ - `message_comment.is_author` is `0` for received messages, `1` for ones this coach
97
+ sent. `parent_id` holds a reply's parent comment id (threaded `replies[]` are
98
+ flattened into rows); `reactions` keeps the API's array as JSON.
99
+ - `message_send.py send` write-throughs the created comment so the store stays
100
+ correct without a re-sync; the next `messaging_sync.py` then advances the cursor.
101
+
102
+ ## `sync_state` — incremental watermarks
103
+
104
+ One row per `(resource, scope_id)` — `scope_id` is an athlete/program id, or 0
105
+ for global. Columns: `cursor` (ISO date / high-water mark), `synced_at`,
106
+ `generation` (set only for prune-to-match zones). The library sync writes a
107
+ `library` cursor. Any new incremental sync must record its watermark here, not in
108
+ `sync_meta` (which holds only the library TTL timestamp and generation counter).
109
+
110
+ ## Conventions for extending the store
111
+
112
+ - **Source tagging.** Every records table has `source` (default `'api'`). Write
113
+ synthetic/test rows with `source='seed'` so production queries can filter them
114
+ and a re-seed can delete only its own rows.
115
+ - **Pick the right zone rule.** Reference-style catalogs may prune-to-match;
116
+ anything historical must accumulate.
117
+
118
+ ## Known limitations
119
+
120
+ - **No API write path for performance history.** Logged sets, readiness, and
121
+ working-max data are produced by the athlete mobile app; they are not creatable
122
+ or (as far as is known) syncable through this coach API. That is why
123
+ there is no performance zone here.
124
+ - **Units are per-athlete.** Weights live in each athlete's own unit (metric vs.
125
+ imperial). Normalize before any cross-athlete analytics.
126
+ - **`swaps` is empty from the bulk endpoint** — it likely only appears on
127
+ per-exercise detail or create responses.
@@ -0,0 +1,310 @@
1
+ # Workout Creation Playbook
2
+
3
+ Building a workout is a multi-step sequence. Each step depends on an ID returned
4
+ by the previous one, so run them in order and capture the IDs. The exercise-add
5
+ step (step 4) is the one that returns HTTP 500 when fields are missing, so follow
6
+ its field list exactly.
7
+
8
+ All calls use the coach base (`https://api.trainheroic.com`) with the
9
+ `session-token` header. With the client: `$TH request POST <path> '<json>'`.
10
+
11
+ > **Prefer the builder.** the workout builder runs this whole sequence
12
+ > from a JSON spec and already fills the 500-prone fields and handles RPE
13
+ > correctly. Reach for the manual steps below only when debugging or doing
14
+ > something the builder does not cover.
15
+ >
16
+ > **RPE rule (verified the hard way):** never prescribe RPE as a structured param.
17
+ > `param_2_type: 14` is silently overridden to weight on any lift whose library
18
+ > default param is weight, so the RPE values render as pounds. Put RPE in the
19
+ > exercise `instruction` ("RPE 8"), prescribe reps, and leave load blank.
20
+
21
+ ## Step 1 — Get a program/calendar to write to
22
+
23
+ Every team has an auto-created calendar (a "program"). Create a team if you need a
24
+ fresh target:
25
+
26
+ ```
27
+ POST /1.0/coach/team/createWithTitleAndCode
28
+ { "title": "My Team" }
29
+ ```
30
+
31
+ The response and its side-effect loads expose the calendar/program ID (e.g.
32
+ `4713246`). Existing programs come from `GET /1.0/coach/programs` (use
33
+ `group_program` as the calendar ID for `/3.0/coach/program/{id}`).
34
+
35
+ ## Step 2 — Create an empty session on a date
36
+
37
+ Date-based (team calendar):
38
+
39
+ ```
40
+ POST /2.0/coach/calendar/workout/createWorkoutForDay/{calendarId}/{year}/{month}/{day}/0
41
+ ```
42
+
43
+ Timeline-based (relative-day programs):
44
+
45
+ ```
46
+ POST /2.0/coach/calendar/workout/createWorkoutForTimelineDay/{programId}/{day}/null
47
+ ```
48
+
49
+ Pass an empty JSON body (`{}`). The response is a session object. Capture two
50
+ IDs from it (verified live):
51
+
52
+ - `workout_id` — the workout, needed by step 3 for blocks.
53
+ - `id` — the programWorkout id, needed by step 5 to publish (and by
54
+ `removeProgramWorkout` as `pwId`).
55
+
56
+ ## Step 3 — Add a block to the session
57
+
58
+ ```
59
+ POST /2.0/coach/calendar/saveProgramWorkoutSets
60
+ [{
61
+ "workout_id": 140318787,
62
+ "order": 1,
63
+ "type": 4,
64
+ "instruction": "",
65
+ "is_redzone": null,
66
+ "redzone_type": 0,
67
+ "exercises": [],
68
+ "exerciseKeys": [],
69
+ "key": "k::9292",
70
+ "title": "Strength/Power"
71
+ }]
72
+ ```
73
+
74
+ `type` values: `1` = Conditioning, `2` = Hypertrophy, `4` = Strength/Power. The
75
+ endpoint accepts an array, so multiple blocks can be created in one call. The
76
+ response is an array of the created blocks; each block's `id` is its
77
+ `workout_set_id`, which step 4 needs (the `key` you send comes back as `null`,
78
+ so match blocks by `order`/`title`).
79
+
80
+ ## Step 4 — Add exercises to the block (the 500-prone step)
81
+
82
+ ```
83
+ POST /2.0/coach/calendar/saveWorkoutSetExercises
84
+ [ exercise1, exercise2, ... ]
85
+ ```
86
+
87
+ Every exercise object must include the full field set below. Omitting any of the
88
+ "critical" fields returns HTTP 500.
89
+
90
+ ```json
91
+ {
92
+ "exercise_id": 1,
93
+ "workout_set_id": 671135671,
94
+ "set_id": 671135671,
95
+ "title": "Back Squat",
96
+ "instruction": "",
97
+ "order": 1,
98
+ "param_1_type": 3,
99
+ "param_2_type": 1,
100
+ "param_1_data_1": "5",
101
+ "param_1_data_2": "",
102
+ "param_1_data_3": "",
103
+ "param_1_data_4": "",
104
+ "param_1_data_5": "",
105
+ "param_1_data_6": "",
106
+ "param_1_data_7": "",
107
+ "param_1_data_8": "",
108
+ "param_1_data_9": "",
109
+ "param_1_data_10": "",
110
+ "param_2_data_1": "225",
111
+ "param_2_data_2": "",
112
+ "param_2_data_3": "",
113
+ "param_2_data_4": "",
114
+ "param_2_data_5": "",
115
+ "param_2_data_6": "",
116
+ "param_2_data_7": "",
117
+ "param_2_data_8": "",
118
+ "param_2_data_9": "",
119
+ "param_2_data_10": "",
120
+ "workout_set_exercise_template_id": null,
121
+ "no_sets": 0,
122
+ "param_count": 3,
123
+ "set_num": 3,
124
+ "key": "k::5001",
125
+ "setKey": 671135671,
126
+ "video_url": "",
127
+ "thumbnail_url": "",
128
+ "tags": [],
129
+ "eType": "e",
130
+ "use_count": 0
131
+ }
132
+ ```
133
+
134
+ Fields that cause a 500 when missing:
135
+
136
+ - `set_num` — number of sets (mirror `param_count`)
137
+ - `key` — any unique string in the form `"k::<number>"`
138
+ - `setKey` — equal to `workout_set_id`
139
+ - All 10 `param_1_data_N` and all 10 `param_2_data_N` slots — unused ones are `""`
140
+ - `eType` — `"e"` for an exercise
141
+ - `tags` — array, may be `[]`
142
+ - `video_url`, `thumbnail_url` — strings, may be `""`
143
+ - `use_count` — integer, may be `0`
144
+ - `workout_set_exercise_template_id` — `null`
145
+
146
+ The API ignores the `title` you send and substitutes the real title for
147
+ `exercise_id`. It may also override `param_2_type` from the exercise's defaults.
148
+
149
+ ## Step 5 — Publish
150
+
151
+ ```
152
+ POST /2.0/coach/calendar/programWorkout/publish
153
+ [ 142002657 ] // array of programWorkout IDs
154
+ ```
155
+
156
+ ## (Optional) Session note — Coach Instructions
157
+
158
+ The session-level note (the day-note shown at the top of a session — greeting +
159
+ writeup) is set with a PUT to the programWorkout, not on a block. Use the same
160
+ `workout_id` from step 2:
161
+
162
+ ```
163
+ PUT /3.0/coach/workout/{workout_id}
164
+ { ...the full programWorkout object..., "instruction": "Welcome to Week 12..." }
165
+ ```
166
+
167
+ Build the body from the session object you already have (the create response, or a
168
+ day's entry from `/1.0/coach/programs/edit`), set `instruction`, and replace
169
+ `sets`/`setKeys` with a flat **list** of block ids sorted by `order` — the edit-GET
170
+ returns `sets` as a dict keyed by block id, so convert it. This does **not** publish:
171
+ `published` is echoed back as sent, so set the note **before** step 5 if the session
172
+ should stay a draft. the workout builder does all of this when the spec has a
173
+ top-level `"instruction"`.
174
+
175
+ ## Reading a session back (verified)
176
+
177
+ To confirm what was built on a team calendar date:
178
+
179
+ ```
180
+ GET /1.0/coach/programs/edit/{calendarId}/{year}/{month}/{day}
181
+ ```
182
+
183
+ This returns `programWorkouts`, an array of every session on the calendar (it is
184
+ not scoped to the single date you pass). Match the one you built by the `id` you
185
+ captured at create time — do not assume `programWorkouts[0]`. On the matched
186
+ session, `published` is `1` once published, and blocks live under its `sets`
187
+ object keyed by `workout_set_id`; each block has an `exercises` array with the
188
+ rendered `title`, `instruction`, and `param_*_data_N` values.
189
+
190
+ ---
191
+
192
+ ## Parameter types
193
+
194
+ | Value | Meaning | Display |
195
+ | ----- | ------------------- | -------------- |
196
+ | 0 | None | (no parameter) |
197
+ | 1 | Weight | `225 lb` |
198
+ | 2 | Weight (% of max) | `75%` |
199
+ | 3 | Reps | `5` |
200
+ | 4 | Time (seconds) | `1:00` |
201
+ | 5 | Distance (yards) | `50yd` |
202
+ | 6 | Distance (meters) | `50m` |
203
+ | 7 | Height | inches |
204
+ | 10 | Distance (miles) | miles |
205
+ | 11 | Distance (feet) | feet |
206
+ | 12 | Height (inches) | inches |
207
+ | 13 | Heart Rate | bpm |
208
+ | 14 | RPE | rating |
209
+ | 18 | Time (seconds, alt) | `0:30` |
210
+
211
+ Most common combos: `p1=3,p2=1` (reps @ weight, e.g. Back Squat), `p1=3,p2=0`
212
+ (reps only / bodyweight, e.g. Push-Up), `p1=3` with `p2` absent (reps only),
213
+ `p1=5` (distance in yards), `p1=4` (time).
214
+
215
+ `param_1_data_N` is the value for param 1 in set N; `param_2_data_N` likewise for
216
+ param 2. `param_count` is the number of sets (max 10).
217
+
218
+ ### The unit is fixed per exercise (verified)
219
+
220
+ On save the API **discards the `param_1_type`/`param_2_type` you send and restores
221
+ the exercise's library defaults.** Your `param_*_data_N` values are kept, so a value
222
+ sent under the wrong assumed unit silently renders under the exercise's real unit.
223
+
224
+ - **`param_1_type` (primary): always forced to the exercise default.** You cannot
225
+ change the primary unit at prescribe time. Stock `Run` (id 82) is miles, so a
226
+ "200 m run" written on `Run` renders as _200 miles_. For meters use a meters-native
227
+ exercise (`Sprint` 127, `Rowing` 101, `Shuttle Sprint` 42523) or a custom exercise.
228
+ - **`param_2_type` (secondary): forced to the default too, except** you may add weight
229
+ (`1`) to an exercise that has no secondary param (default `0`/none) — that is how
230
+ weighted Pull-Ups/Dips work. `2` (% of max) and `14` (RPE) do **not** stick on a
231
+ weight-default lift; both coerce to weight and render as pounds.
232
+ - Check an exercise's real units first: `$TH exercise resolve "<name>"` prints a
233
+ `units` array, ordered by entry slot (`[param 1, param 2]`). the workout builder also
234
+ prints a `WARNING` when a sent param type will be overridden, and its read-back labels
235
+ the stored units.
236
+
237
+ ## Prescription patterns
238
+
239
+ - **Superset**: send multiple exercises in one array, same `workout_set_id`/
240
+ `set_id`, different `order`. They render as A1, A2, ...
241
+ - **Drop set**: one exercise, decreasing weight and increasing reps across the
242
+ `_data_N` slots.
243
+ - **Pyramid**: vary both params up then down across the slots
244
+ (e.g. reps `5,3,1,3,5` at weight `185,225,275,225,185`).
245
+ - **Bodyweight**: `param_2_type: 0` and leave every `param_2_data_N` empty.
246
+ - **Weighted bodyweight** (weighted Pull-Up/Dip): add `param_2_type: 1` with loads.
247
+ Adding weight sticks _only_ on exercises whose default secondary is none (`0`).
248
+ - **Max / AMRAP reps (verified)**: the rep slots are free text — put the literal
249
+ string `"Max"` (or `"AMRAP"`, `"ME"`) in `param_1_data_N`; it round-trips verbatim
250
+ and you can mix it with numbers (e.g. last set `"Max"`). The builder accepts
251
+ `"reps": ["Max", "Max"]` or `"reps": [5, 5, "Max"]`.
252
+ - **RPE and % of max (verified caveat)**: `param_2_type: 14` (RPE) and `2` (% of max)
253
+ do **not** stick on exercises whose library default param is weight — the API
254
+ overrides them back to weight, so the numbers render as pounds. Reliable approach:
255
+ prescribe reps with `param_2_type: 0` (load left blank for athlete autoregulation)
256
+ and put the target in the exercise `instruction` (e.g. `"RPE 8"` or `"75% of max"`).
257
+ The `instruction` field round-trips intact. (This is one case of the fixed-unit
258
+ rule above.)
259
+
260
+ ## Common exercise IDs
261
+
262
+ | ID | Title | p1 | p2 |
263
+ | ---- | ----------- | --- | --- |
264
+ | 1 | Back Squat | 3 | 1 |
265
+ | 3 | Front Squat | 3 | 1 |
266
+ | 7 | Pull-Up | 3 | 0 |
267
+ | 24 | Burpee | 3 | 0 |
268
+ | 36 | Air Squat | 3 | 0 |
269
+ | 67 | Plank | 4 | 0 |
270
+ | 100 | Push-Up | 3 | 0 |
271
+ | 424 | Deadlift | 3 | 1 |
272
+ | 1162 | Bench Press | 3 | 1 |
273
+
274
+ Full library: `GET /v5/exerciseLibrary/all`. Custom exercises:
275
+ `POST /2.0/coach/exercise/create` with your own `title`, `param_1_type`,
276
+ `param_2_type`.
277
+
278
+ ## Leaderboards (Red Zone)
279
+
280
+ A block can be a **leaderboard** — TrainHeroic's "Red Zone" competition score. The
281
+ UI shows a trophy and "FOR <UNIT>". It is encoded on the block, not the exercise,
282
+ and is a separate unit system (it has Feet/Meters/Calories even when the exercise
283
+ param is locked to another unit):
284
+
285
+ - `redzone_type` = the score unit; setting it `> 0` flags `is_redzone = 1`.
286
+ - `smaller_is_better = 1` makes the lowest score win (use for Time/Seconds).
287
+ - `redzone_instruction` = optional scoring note.
288
+
289
+ `redzone_type` values: `0` For Completion, `1` Weight, `2` Reps, `3` Rounds,
290
+ `4` Time, `5` Yards, `6` Meters, `7` Feet, `8` Calories, `10` Miles, `12` Inches,
291
+ `15` Watts, `17` Velocity, `18` Seconds.
292
+
293
+ In the workout builder add `"leaderboard"` to a block: a unit string (`"rounds"`,
294
+ `"time"`, `"calories"`, ...) or `{"unit": "time", "lowest_wins": true,
295
+ "instruction": "..."}`. Time/Seconds default to lowest-wins.
296
+
297
+ **When to set one (ask if unclear).** For an **AMRAP**, score = `rounds` (or `reps`);
298
+ for a **"for time"** workout, score = `time` (lowest wins); for a max-distance/row,
299
+ the distance/calorie unit. If the coach's intent is ambiguous, ask rather than guess.
300
+ For an AMRAP also program **multiple sets** (one per expected round) — assume a fit
301
+ athlete's round count (e.g. ~5–6 rounds for a ~10–12 min triplet) and confirm if unsure.
302
+
303
+ ## Editing and managing sessions
304
+
305
+ - Set the session note (Coach Instructions): `PUT /3.0/coach/workout/{workoutId}`
306
+ with the programWorkout object + `instruction` (does not change publish state).
307
+ - Unpublish: `POST /2.0/coach/calendar/programWorkout/unPublish/{programWorkoutId}`
308
+ - Delete: `POST /2.0/coach/calendar/removeProgramWorkout` `{ "programId", "pwId" }`
309
+ - Copy/repeat to a date: `POST /2.0/coach/calendar/copyProgramWorkout`
310
+ - Save as template: `POST /2.0/coach/calendar/programWorkout/saveWorkoutAsTemplate/{workoutId}`