Workout results — behavior spec
Capped time scores
A time-scored workout or section can record isCapped: true and nonnegative integer repsAtCap. The workout must have a positive timeCap, or exactly one timed section (for_time, rounds, or rep_scheme) with config.timeCapMinutes. A per-section time score uses that section’s cap. The API validates capped writes against the assignment’s frozen workout snapshot.
A capped result has scoreNumeric = NULL; it never counts as a faster finishing time or automatic time PR. Time leaderboards rank finishers, then capped entries by completed reps descending, then unscored entries. Equal capped reps keep Rx before scaled. A notes-only update preserves capped state; changing a linked automatic fastest-time PR to capped replaces it with the next finished Rx result or retires it. Manually entered PRs are unaffected.
Canonical numeric encoding
| Scoring | Canonical numeric | Display format |
|---|---|---|
time | seconds (integer or float) | mm:ss or hh:mm:ss |
rounds_reps | rounds * 1000 + reps (AMRAP) | R+r (e.g. 5+12) |
reps / weight / distance / calories / points | raw number | integer or 2-decimal float |
none | not stored | not displayed |
scoreNumeric is numeric(14,4) in the DB. Parsing happens in parseScoreInput(scoring, raw) — non-parseable strings are rejected with 400 rather than silently stored as null.
Per-set storage is fully canonical: weight_kg numeric(8,3), distance_m numeric(10,3), duration_seconds integer. The athlete-entered unit is preserved in weight_display_unit/distance_display_unit so the UI can format back to lb/mi/ft without re-converting and accumulating drift on subsequent edits.
Conversions (workout-results.service.ts:84+):
toKilograms(value, unit)—lb/lbs→ kg via* 0.453592.toMetres(value, unit)—km→ 1000,mi→ 1609.344,ft→ 0.3048.toSeconds(input)—mm:ss,hh:mm:ss, or raw integer seconds.
PR detection algorithm (checkIsPR)
Runs on every new result whose workout has scoring !== 'none'.
prev_results = all this user's results for libraryWorkoutId, not deleted
current = the new result
others = prev_results minus current, with scoreNumeric != null
if others.isEmpty:
persist PR (first time = PR)
return true
lower_is_better = (workout.scoring == 'time')
is_best = for each r in others:
current <= r.score if lower_is_better
current >= r.score otherwise
if is_best:
persist PR
return is_bestPersisted PR rows go into personal_records. Workout-level PR: keyed by (user_id, library_workout_id). Exercise-level PR: keyed by (user_id, exercise_id), written only when the workout has exactly one non-deleted movement AND scoring='weight'.
PR upsert is idempotent — upsertPR reads the existing row by partial unique index, updates value_numeric + achieved_at + workout_result_id if better, or inserts new.
Invariants
- Snapshot anchor.
workout_results.snapshot_workout_idalways references a workout row. Assignment results use the athlete snapshot. Logging without an assignment against a mutable workout also creates an immutable release in the result transaction and maps logged sets to its copied movement IDs. A shared source lock prevents a concurrent exercise fork from changing that definition during the copy; a source changed since initial validation returns a conflict for retry. - Library denormalization.
library_workout_id = snapshot.forked_from_id ?? snapshot.id. Lets per-canonical leaderboards run as a single indexed scan instead of walking the fork chain. - Exactly one of
personalRecords.exerciseId/libraryWorkoutId. DB CHECKpersonal_records_target_exclusive_chk. - Soft delete only on results.
deleted_at-filtered everywhere. - Owner of the result.
update/removereject whenexisting.userId !== requester.id(403). - Org scope.
update/removereject ifexisting.organizationId !== orgId(403). personalRecordsupsert via partial unique indexes —personal_records_user_exercise_unique(WHEREexerciseId IS NOT NULL AND deleted_at IS NULL) andpersonal_records_user_workout_unique.- Per-part scores belong to one result.
workout_result_part_scoreshangs off the member’s singleworkout_resultsrow for the assignment, keyed(result_id, section_id, slot_key); the row is created by the first part save and carries no workout-level score of its own. - A part save never completes the assignment. Only Finish (
POST assignments/:id/complete) and the legacyPOST .../resultsdo. - Each slot parses through its own scoring, using the same
parseScoreInput/formatScoregrammar as the workout-level score —score_valueis the re-rendered string,score_numericthe canonical number,score_unitwhat the athlete entered or the scoring’s canonical unit. partson every result response.[]for legacy results and for workouts whose sections define no slots.
Golden path — log a result with sets
- Member finishes Fran.
- UI calls
POST /organizations/:orgId/workouts/<snapshotOrLibraryWorkoutId>/results:{ "assignmentId": "<assignmentId>", "scoreValue": "5:42", "rx": true, "scaled": false, "setResults": [ { "exerciseId": "<thruster>", "setNumber": 1, "reps": 21, "weight": "42.5", "weightUnit": "kg" }, { "exerciseId": "<thruster>", "setNumber": 2, "reps": 15, "weight": "42.5", "weightUnit": "kg" } ] } - Service resolves the workout in the URL, parses score (
5:42→ 342 seconds forscoring='time'). - If
assignmentId:requireAssignmentForWorkoutproves the assignment is in this org, references the URL workout, and is assigned to the caller (404 otherwise, staff included — a result is always the caller’s own). ThenforkSnapshotIfNeededreturns the snapshot id (creating one on first edit/completion).snapshotWorkoutIdandlibraryWorkoutIdset accordingly. - Inserts the
workout_resultsrow +workout_set_resultsrows (one per set). - UPDATE on the assignment (matched by
assignmentIdANDstatus='assigned'): status→completed. - Runs
checkIsPR. If yes, upsertspersonal_records(workout-level; exercise-level if single-movement weight). Returns{ ...result, isPR: true|false }.
Golden path — class WOD scored after Done
- Member opens a class WOD from a session (no
assignmentId) and taps Done. - UI calls
POST .../resultswith{ "scoreValue": "" }— the result exists with a NULL score, so no PR can fire yet. - Member taps Save score. UI calls
PATCH .../results/:idwith{ scoreValue, scoreUnit, rx, scaled, setResults? }. - In one transaction the service updates the score fields, replaces the result’s
workout_set_resultsrows whensetResultsis present (an empty array clears them), and re-runs the same automatic workout-PR checkcreateuses against the merged row. Returns{ ...result, isPR }. - A PATCH that touches no score field (e.g. notes only) skips PR detection and returns
isPR: false.
Golden path — log one part of the whiteboard
- The coach defined score slots on a section (
workout_sections.scores); the member’s app shows a tag per slot on that part. - Member fills the part and the app calls
PUT /organizations/:orgId/assignments/:assignmentId/parts/:sectionId:{ "scores": [ { "slotKey": "s1", "value": "120" }, { "slotKey": "s2", "value": "48" } ], "setResults": [ { "workoutMovementId": "<row>", "exerciseId": "<row>", "setNumber": 1, "distance": "1000", "distanceUnit": "m" } ] } - Service proves the assignment is the caller’s own (404 otherwise), resolves the section in the workout the member is reading, checks every
slotKeyagainst that section’sscoresand every set against that section’s movements. forkSnapshotIfNeededruns exactly as in result creation; when it forks, the part and its sets are re-pointed at the copy’s section/movements bysortOrder.- In one transaction: find-or-create the assignment’s
workout_resultsrow (status untouched), delete the slots no longer sent, upsert the rest, and — whensetResultsis present — replace the sets of THIS section’s movements only. - Response is the result with
parts, so the whiteboard can render the logged part without a second read. - Sending
{ "scores": [] }with no sets clears the part; the result row stays for the other parts. - Member taps Finish →
POST assignments/:id/complete. Unlogged parts simply stay empty.
Golden path — manual PR
- Member visits profile → PRs → “Log PR”.
- UI calls
POST /personal-records/me{ exerciseId: <backSquatId>, value: "120", unit: "kg", achievedAt }. - Service validates: exactly one of
exerciseId/workoutIdset, exercise resolves to canonical or this org’s row, value parses to number. - Upserts by partial unique index.
workoutResultIdset tonull(manual entry).
Edge cases & error states
| Trigger | Handling |
|---|---|
scoreValue doesn’t parse for the workout’s scoring | 400 with the offending input quoted. |
setResults with non-numeric weight / distance | Stored as null silently (TODO: verify if this should error). |
assignmentId from another org, for another workout, or assigned to another member | 404 “Assignment not found”; nothing forked or written. |
assignmentId for a non-workout-kind assignment | forkSnapshotIfNeeded rejects → 400 from result creation. |
update on another user’s result | 403. |
update on a result in another org | 403. |
Manual PR without exerciseId or workoutId | 400. |
| Manual PR with both | TODO: verify — service may accept one preferentially. |
| Result on a deleted workout | URL workout lookup fails — 404. |
PR detection on scoring='none' workout | Returns false, no PR row. |
Result auto-completion match falls back to (library, user, date) when assignmentId is omitted | Matches any assigned-status assignment for that user + library + date. May misfire if two assignments exist on the same day. |
Side effects
- Lazy-fork of the snapshot (via
forkSnapshotIfNeeded). - Assignment auto-completion — UPDATE status→
completed,completed_atset. - PR upsert — insert or update
personal_records. - No push notification on result log or PR achievement at present.
Permissions
| Action | Required role |
|---|---|
| Log / update / delete own result | Member (requireMembership only — service self-checks userId). |
| Read leaderboard / latest / own results | Any member of the org. |
| Read another member’s PRs | Any member of the org (note: PRs are not currently privacy-gated). |
Read another member’s full result history (findMemberResults) | owner / admin / coach only. |
| Manual PR | Member (self). |