Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkout ResultsWorkout results — behavior spec

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

ScoringCanonical numericDisplay format
timeseconds (integer or float)mm:ss or hh:mm:ss
rounds_repsrounds * 1000 + reps (AMRAP)R+r (e.g. 5+12)
reps / weight / distance / calories / pointsraw numberinteger or 2-decimal float
nonenot storednot 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_best

Persisted 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_id always 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 CHECK personal_records_target_exclusive_chk.
  • Soft delete only on results. deleted_at-filtered everywhere.
  • Owner of the result. update / remove reject when existing.userId !== requester.id (403).
  • Org scope. update / remove reject if existing.organizationId !== orgId (403).
  • personalRecords upsert via partial unique indexes — personal_records_user_exercise_unique (WHERE exerciseId IS NOT NULL AND deleted_at IS NULL) and personal_records_user_workout_unique.
  • Per-part scores belong to one result. workout_result_part_scores hangs off the member’s single workout_results row 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 legacy POST .../results do.
  • Each slot parses through its own scoring, using the same parseScoreInput/formatScore grammar as the workout-level score — score_value is the re-rendered string, score_numeric the canonical number, score_unit what the athlete entered or the scoring’s canonical unit.
  • parts on every result response. [] for legacy results and for workouts whose sections define no slots.

Golden path — log a result with sets

  1. Member finishes Fran.
  2. 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" } ] }
  3. Service resolves the workout in the URL, parses score (5:42 → 342 seconds for scoring='time').
  4. If assignmentId: requireAssignmentForWorkout proves 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). Then forkSnapshotIfNeeded returns the snapshot id (creating one on first edit/completion). snapshotWorkoutId and libraryWorkoutId set accordingly.
  5. Inserts the workout_results row + workout_set_results rows (one per set).
  6. UPDATE on the assignment (matched by assignmentId AND status='assigned'): status→completed.
  7. Runs checkIsPR. If yes, upserts personal_records (workout-level; exercise-level if single-movement weight). Returns { ...result, isPR: true|false }.

Golden path — class WOD scored after Done

  1. Member opens a class WOD from a session (no assignmentId) and taps Done.
  2. UI calls POST .../results with { "scoreValue": "" } — the result exists with a NULL score, so no PR can fire yet.
  3. Member taps Save score. UI calls PATCH .../results/:id with { scoreValue, scoreUnit, rx, scaled, setResults? }.
  4. In one transaction the service updates the score fields, replaces the result’s workout_set_results rows when setResults is present (an empty array clears them), and re-runs the same automatic workout-PR check create uses against the merged row. Returns { ...result, isPR }.
  5. 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

  1. The coach defined score slots on a section (workout_sections.scores); the member’s app shows a tag per slot on that part.
  2. 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" } ] }
  3. Service proves the assignment is the caller’s own (404 otherwise), resolves the section in the workout the member is reading, checks every slotKey against that section’s scores and every set against that section’s movements.
  4. forkSnapshotIfNeeded runs exactly as in result creation; when it forks, the part and its sets are re-pointed at the copy’s section/movements by sortOrder.
  5. In one transaction: find-or-create the assignment’s workout_results row (status untouched), delete the slots no longer sent, upsert the rest, and — when setResults is present — replace the sets of THIS section’s movements only.
  6. Response is the result with parts, so the whiteboard can render the logged part without a second read.
  7. Sending { "scores": [] } with no sets clears the part; the result row stays for the other parts.
  8. Member taps Finish → POST assignments/:id/complete. Unlogged parts simply stay empty.

Golden path — manual PR

  1. Member visits profile → PRs → “Log PR”.
  2. UI calls POST /personal-records/me { exerciseId: <backSquatId>, value: "120", unit: "kg", achievedAt }.
  3. Service validates: exactly one of exerciseId / workoutId set, exercise resolves to canonical or this org’s row, value parses to number.
  4. Upserts by partial unique index. workoutResultId set to null (manual entry).

Edge cases & error states

TriggerHandling
scoreValue doesn’t parse for the workout’s scoring400 with the offending input quoted.
setResults with non-numeric weight / distanceStored as null silently (TODO: verify if this should error).
assignmentId from another org, for another workout, or assigned to another member404 “Assignment not found”; nothing forked or written.
assignmentId for a non-workout-kind assignmentforkSnapshotIfNeeded rejects → 400 from result creation.
update on another user’s result403.
update on a result in another org403.
Manual PR without exerciseId or workoutId400.
Manual PR with bothTODO: verify — service may accept one preferentially.
Result on a deleted workoutURL workout lookup fails — 404.
PR detection on scoring='none' workoutReturns false, no PR row.
Result auto-completion match falls back to (library, user, date) when assignmentId is omittedMatches 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_at set.
  • PR upsert — insert or update personal_records.
  • No push notification on result log or PR achievement at present.

Permissions

ActionRequired role
Log / update / delete own resultMember (requireMembership only — service self-checks userId).
Read leaderboard / latest / own resultsAny member of the org.
Read another member’s PRsAny member of the org (note: PRs are not currently privacy-gated).
Read another member’s full result history (findMemberResults)owner / admin / coach only.
Manual PRMember (self).