Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesScoreboardPart scoreboards

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

PersonaRole
MemberViews boards for their own assignment, reacts to scores, sets visibility and reaction preferences.
Coach / admin / ownerHides and unhides scores; reads boards with real names through Spotter / MCP.

Behavior and rules

Gating

  • Tier — ScoreboardController carries class-level @RequiresFeature('leaderboards') (board, reactions, reactors, hide/unhide). ScoreboardMemberController has no tier guard: the summary answers enabled: false when the tier lacks leaderboards; preferences are always available.
  • Preferences — GET and PATCH always read and write the settings (never 403/404) and also return enabled = plan has leaderboards AND the caller is an adult. Clients use it to decide whether to show the settings.
  • Coaching programs only (programs.delivery_mode = 'coaching').
  • Guardian forUserId is not supported: requesting a board for someone else’s assignment → 403.

Board key and cohort

  • A board is keyed by program_session_id + section sort_order + slot_key. Only scores whose own section slot has the same scoring as 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 (slotLowerIsBetter in libs/shared): an explicit better: 'higher' | 'lower' on the slot wins; absent, time is lower-is-better and everything else higher-is-better. Coaches set better for slots that run against their scoring (a max hold scored as time, fewest breaks scored as reps). The board response carries the resolved lowerIsBetter, so clients never re-derive it.
  • Time counted down: finishers first, then capped results by reps_at_cap descending, 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_at first.
  • The gender filter (all / male / female) and the division filter (all / rx / level + level) apply before ranking, so ranks restart inside the view. rx keeps parts logged Rx; level keeps parts logged Other at that level. Parts logged with no performance appear only under all.
  • board.levels lists 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; entry is null), or not_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; DELETE removes 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, jobId score-reactions-<partScoreId> (BullMQ ids may not contain :), removeOnComplete/removeOnFail true so the id frees after each run.
  • since is 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 since and sends one localized push through PushNotificationsService.notifyUser, category scoreReactions, 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.

MethodPathShape
GET/assignments/:assignmentId/scoreboard-summaryscoreboardSummarySchema
GET/assignments/:assignmentId/scoreboard?sectionId&slotKey&gender&division&level&cursor&limitquery scoreboardQuerySchema (limit default 50, max 100; level required with division=level) → scoreboardResponseSchema
PUT/scoreboard/scores/:partScoreId/reactionbody setScoreReactionSchema
DELETE/scoreboard/scores/:partScoreId/reaction—
GET/scoreboard/scores/:partScoreId/reactionsscoreReactorsResponseSchema
GET/members/me/scoreboard-preferencesscoreboardPreferencesResponseSchema (settings + enabled)
PATCH/members/me/scoreboard-preferencesbody updateScoreboardPreferencesSchema → scoreboardPreferencesResponseSchema
POST / DELETE/scoreboard/scores/:partScoreId/hidescoreboardHideResponseSchema

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

PathPurpose
apps/api/src/scoreboard/scoreboard.service.tsCohort, boards, reactions, preferences, hide
apps/api/src/scoreboard/scoreboard-ranking.tsNormalization, ordering, competition ranks
apps/api/src/scoreboard/scoreboard.controller.tsTier-gated routes
apps/api/src/scoreboard/scoreboard-member.controller.tsSummary and preferences
apps/api/src/scoreboard/score-reaction-notifier.service.tsEnqueues and sends the reaction digest
apps/api/src/scoreboard/score-reaction-digest.queue.tsQueue name, delay, job id and options
apps/api/src/scoreboard/score-reaction-digest.processor.tsDigest worker
apps/api/src/scoreboard/scoreboard.module.tsModule wiring
apps/api/src/ai/agent/tools/leaves/scoreboard.tools.tsSpotter leaf
apps/api/src/memberships/member-erasure.service.tsDeletes the member’s reactions
libs/shared/src/lib/schemas/scoreboard.schema.tsZod contracts
libs/shared/src/lib/schemas/notification-prefs.tsscoreReactions category
libs/shared/src/lib/analytics/programming-events.tsAnalytics events
libs/db/src/lib/schema/scoreboard.tsscore_reactions table
libs/db/drizzle/0183_part_scoreboard.sqlscore_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.sqlworkout_result_part_scores.performance / level
libs/shared/src/lib/section-levels.tssectionLevelOptions: a part’s level options from movements and whiteboard text
apps/api/src/scoreboard/scoreboard-ranking.unit.spec.tsRanking 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