Workout results — behavior spec
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 (library or snapshot). Library row used when no assignmentId; snapshot id used when assignmentId triggers a fork. - 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.
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:forkSnapshotIfNeededreturns 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 — 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 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). |