Part scoreboards
Status: In progress Linear: TKN-481
What this is
A ranked scoreboard for each scored part of a coaching-program session. Every adult enrollee who logged a score on a part appears on that part’s board, best first. Members can react to each other’s scores, opt out of being named, or opt out of reactions; staff can take a score off the board.
Who uses it
| Persona | Role |
|---|---|
| Member | Views boards for their own assignment, reacts to scores, sets visibility and reaction preferences. |
| Coach / admin / owner | Hides and unhides scores; reads boards with real names through Spotter / MCP. |
Behavior and rules
Gating
- Tier —
ScoreboardControllercarries class-level@RequiresFeature('leaderboards')(board, reactions, reactors, hide/unhide).ScoreboardMemberControllerhas no tier guard: the summary answersenabled: falsewhen the tier lacksleaderboards; preferences are always available. - Preferences — GET and PATCH always read and write the settings (never 403/404) and also return
enabled= plan hasleaderboardsAND the caller is an adult. Clients use it to decide whether to show the settings. - Coaching programs only (
programs.delivery_mode = 'coaching'). - Guardian
forUserIdis not supported: requesting a board for someone else’s assignment → 403.
Board key and cohort
- A board is keyed by
program_session_id+ sectionsort_order+slot_key. Only scores whose own section slot has the samescoringas the board slot are ranked. - Cohort: live results on live assignments of the session, where the athlete holds an active membership and an active program enrollment, is an adult, and the score is not hidden.
- Adult: not
users.kind = 'managed',birth_date≤ org-today − 18 years (or null), and not a ward in an active guardianship. Minors never appear on boards and cannot react.
Ranking (scoreboard-ranking.ts)
- Direction comes from the board slot (
slotLowerIsBetterinlibs/shared): an explicitbetter: 'higher' | 'lower'on the slot wins; absent,timeis lower-is-better and everything else higher-is-better. Coaches setbetterfor slots that run against their scoring (a max hold scored as time, fewest breaks scored as reps). The board response carries the resolvedlowerIsBetter, so clients never re-derive it. - Time counted down: finishers first, then capped results by
reps_at_capdescending, then zero/negative times last. A lower-is-better non-time slot ranks zero first. - Weight is normalized to kg (
lb/lbs), distance to metres (km/mi/ft/yd) before comparing. - Best score per athlete. Competition ranking (1, 2, 2, 4); ties are ordered earlier
created_atfirst. - The
genderfilter (all/male/female) and thedivisionfilter (all/rx/level+level) apply before ranking, so ranks restart inside the view.rxkeeps parts logged Rx;levelkeeps parts logged Other at that level. Parts logged with no performance appear only underall. board.levelslists the part’s level options from the session release’s section at the board position, so every athlete sees the same choices;[]means the part has no levels and clients hide the division filter.me.status:ranked,filtered_out(on the board but not in the current gender/division view;entryis null), ornot_logged.
Anonymity
memberships.scoreboard_visible = false nulls userId, displayName and imageUrl for everyone except the athlete, on the board and in reactor lists. Spotter shows staff real names.
Reactions
- Six reactions (
SCORE_REACTIONS: fire, muscle, clap, wow, trophy, heart); one per person per score (upsert;DELETEremoves it). - 403 on self-reaction, when the owner disabled reactions (
memberships.score_reactions_enabled = false), or when the caller is not in the session cohort or is a minor. Hidden or unknown score → 404 for setting a reaction; removing my own reaction still works on a hidden score.
Notifications
- A new reaction (not a change of an existing one) queues a BullMQ job on
score-reaction-digest, delay 15 minutes,jobIdscore-reactions-<partScoreId>(BullMQ ids may not contain:),removeOnComplete/removeOnFailtrue so the id frees after each run. sinceis taken before the reaction is written, minus a 5-second margin for clock drift, so a concurrent reaction is counted.- The job counts reactions created since
sinceand sends one localized push throughPushNotificationsService.notifyUser, categoryscoreReactions, route/(tabs)/workouts/<assignmentId>. Skipped when the count is 0, the owner disabled reactions, or staff hid the score; category opt-out is enforced by the push service.
Moderation
Staff hide sets hidden_from_scoreboard_at / hidden_from_scoreboard_by on the part score. The hide stays until staff unhide it: re-saving the part keeps it, re-pointing the part at a personal fork carries it to the same slot of the copied section, and when the member clears the part (or drops the slot) the hidden row is kept rather than deleted, so logging that slot again lands on the same hidden row. Spotter lists hidden scores unranked under hidden.
Erasure
Member erasure deletes the member’s reactions explicitly (the membership is soft-deleted, so the FK cascade does not fire). Reactions on the member’s own scores cascade with their results.
Analytics
PostHog scoreboard_viewed (first page only; props programId, programSessionId, scoring, gender, division, level, totalLogged, meStatus) and score_reaction_sent in ProgrammingEvents. scoreboard_viewed fires at most once per viewer per board view (program session + section sort order + slot + gender + division + level) per 30 minutes, deduped with a Redis SET NX EX key scoreboard:viewed:…; if Redis fails the view is tracked and the request is still served.
Endpoints
All under /organizations/:orgId. Shapes in libs/shared/src/lib/schemas/scoreboard.schema.ts.
| Method | Path | Shape |
|---|---|---|
| GET | /assignments/:assignmentId/scoreboard-summary | scoreboardSummarySchema |
| GET | /assignments/:assignmentId/scoreboard?sectionId&slotKey&gender&division&level&cursor&limit | query scoreboardQuerySchema (limit default 50, max 100; level required with division=level) → scoreboardResponseSchema |
| PUT | /scoreboard/scores/:partScoreId/reaction | body setScoreReactionSchema |
| DELETE | /scoreboard/scores/:partScoreId/reaction | — |
| GET | /scoreboard/scores/:partScoreId/reactions | scoreReactorsResponseSchema |
| GET | /members/me/scoreboard-preferences | scoreboardPreferencesResponseSchema (settings + enabled) |
| PATCH | /members/me/scoreboard-preferences | body updateScoreboardPreferencesSchema → scoreboardPreferencesResponseSchema |
| POST / DELETE | /scoreboard/scores/:partScoreId/hide | scoreboardHideResponseSchema |
Spotter / MCP: read.part_scoreboard (MCP read_part_scoreboard), staff roles, gated on leaderboards via ToolFeatureGate, not flag-gated; boards follow the session’s release sections.
Code map
| Path | Purpose |
|---|---|
apps/api/src/scoreboard/scoreboard.service.ts | Cohort, boards, reactions, preferences, hide |
apps/api/src/scoreboard/scoreboard-ranking.ts | Normalization, ordering, competition ranks |
apps/api/src/scoreboard/scoreboard.controller.ts | Tier-gated routes |
apps/api/src/scoreboard/scoreboard-member.controller.ts | Summary and preferences |
apps/api/src/scoreboard/score-reaction-notifier.service.ts | Enqueues and sends the reaction digest |
apps/api/src/scoreboard/score-reaction-digest.queue.ts | Queue name, delay, job id and options |
apps/api/src/scoreboard/score-reaction-digest.processor.ts | Digest worker |
apps/api/src/scoreboard/scoreboard.module.ts | Module wiring |
apps/api/src/ai/agent/tools/leaves/scoreboard.tools.ts | Spotter leaf |
apps/api/src/memberships/member-erasure.service.ts | Deletes the member’s reactions |
libs/shared/src/lib/schemas/scoreboard.schema.ts | Zod contracts |
libs/shared/src/lib/schemas/notification-prefs.ts | scoreReactions category |
libs/shared/src/lib/analytics/programming-events.ts | Analytics events |
libs/db/src/lib/schema/scoreboard.ts | score_reactions table |
libs/db/drizzle/0183_part_scoreboard.sql | score_reaction enum, score_reactions, memberships.scoreboard_visible, memberships.score_reactions_enabled, workout_result_part_scores.hidden_from_scoreboard_at / hidden_from_scoreboard_by |
libs/db/drizzle/0184_part_score_levels.sql | workout_result_part_scores.performance / level |
libs/shared/src/lib/section-levels.ts | sectionLevelOptions: a part’s level options from movements and whiteboard text |
apps/api/src/scoreboard/scoreboard-ranking.unit.spec.ts | Ranking unit tests |
apps/api/src/scoreboard/__tests__/scoreboard.int.spec.ts (+ scoreboard.driver.ts) | Integration tests against Postgres: cohort, ranking, gender view, me, anonymity, reactions and digest, minors, hidden scores, tier gating, staff boards |
Related
- workout-results — part scores that boards rank.
- push-notifications — delivers the reaction digest.