Leaderboard Model & Result Classification
1. Overview
A leaderboard turns the individual race results of a series into a ranked set of standings — who is winning the season, which school has the most points, which team leads the club competition. This page describes the durable model: the schema that stores a leaderboard’s configuration, and the way race results are scored and classified into standings.
This is the foundation. It is deliberately separate from two other concerns:
-
How a specific league is set up — the operational steps to stand up the leaderboards for a real series — lives in Operational Procedures (Western Cape Schools Cycling and WP Cycling Road League).
-
How standings recalculate automatically when results change — the event-driven sync layer — lives in Leaderboard Synchronization. That layer builds on this model; this model stands on its own and is in production use today via manual aggregation.
|
Automated recalculation (Leaderboard Sync) is deferred. Today,
standings are aggregated manually (SQL or admin tooling) by reading |
2. The two-stage pipeline
Producing standings is a two-stage pipeline. The model keeps the two stages cleanly separated — they are configured independently and implemented by two different abstractions.
Race finish positions Stage 1: SCORING Stage 2: AGGREGATION
┌───────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ RaceResult.position│─────▶│ PointsCalculator │───▶│ LeaderboardCalculator │───▶ Standings
│ (1, 2, 3, …) │ │ position → points │ │ group + sum + rank │
└───────────────────┘ └──────────────────────┘ └──────────────────────┘
per-series scoring scale per-LeaderboardType strategy
-
Scoring (
PointsCalculator) converts a finishing position into points using the series' scoring scale. Points are written toRaceResult.pointsat result-import time. -
Aggregation (
LeaderboardCalculator) groups the scored results by the leaderboard’s subject (rider, school, club/team), sums the points, and ranks them into standings. The strategy is chosen by the leaderboard’sLeaderboardType.
Keeping these separate is what lets the same scoring scale feed several different leaderboards, and the same aggregation strategy work with different scoring scales.
3. Schema
3.1. Leaderboard
The configuration entity. One row per leaderboard (e.g. "Individual — Road League 2026").
| Field | Type | Purpose |
|---|---|---|
|
String(100) |
Display name. |
|
|
Aggregation strategy — see Classification & aggregation: LeaderboardType. |
|
|
Optional gender filter; |
|
Integer |
For TOP_SCHOOLS-style boards: how many best results per subject to count. |
|
Integer |
Max entries shown in the standings. |
|
Boolean |
Whether the board is live. |
|
String(255) |
The scoring scale — short code or fully-qualified |
|
|
The series this board scores. |
|
|
Optional sub-classification filter (e.g. PS vs HS) — see Level filtering (PS vs HS). |
|
|
The CustomList whose values the board groups by (e.g. Schools, Clubs). |
|
|
Which series categories feed this board — see below. |
3.2. LeaderboardCategory
Maps a leaderboard to the series categories whose results it aggregates. This replaces the
old approach of hardcoding series_category_id lists in SQL.
| Field | Type | Purpose |
|---|---|---|
|
|
Owning board. |
|
|
A series-level category that feeds the board. |
|
BigDecimal (nullable) |
Optional weighting applied to this category’s points. |
3.3. Series, ResultSet, RaceResult
The leaderboard model sits on top of the existing results schema:
-
Series— the season/competition. ALeaderboardbelongs to exactly one series; itscategories(EventCategoryrows withseriesset) are the series-level categories thatLeaderboardCategoryreferences. -
ResultSet— a set of results for one event/category/state. Carries thestate(ResultSetState), an optionalleaderboardlink, and (for the sync layer) anerrorMessage. -
RaceResult— one rider’s result within a ResultSet. The fields the model depends on:Field Role in the model positionInput to scoring (
PointsCalculator).pointsOutput of scoring; the value aggregation sums.
seqDeterministic ordering key (positions can tie); also a secondary upsert key.
statusResultStatus— FINISHED / DNS / DNF / RELEGATED / DQ / LAPPED. Non-finishers are scored per series rules.person,eventParticipantThe rider. For a participant-linked result the EP is the authoritative source of the grouping CustomListValues — see Classification source: EventParticipant vs RaceResult.
custom1,custom2,custom3CustomListValue slots that carry grouping (school, club, team) only for participant-less results — team events (the TTT), school-as-entity results, and the materialised standings written by the sync layer. For participant-linked rows these are a fallback, not the source of truth — see Classification source: EventParticipant vs RaceResult.
4. Classification source: EventParticipant vs RaceResult
A result’s classification — the club, team, school, and (via the EP’s category) the series category it groups under — has one authoritative source, chosen by whether the result is tied to a participant:
-
Participant-linked results (the normal case — an individual rider’s finish) classify from the
EventParticipant. The EP holds the live, corrected affiliation: when an operator fixes a rider’s club, merges two clubs, or re-categorises a rider, the change lands on the EP and is immediately reflected in every standing. This is the source of truth. -
Participant-less results classify from
RaceResult.custom1/2/3. Some results have no participant to carry the classification:-
Team events — e.g. the WP Cycling Team Time Trial: one
RaceResultper team witheventParticipant = NULLand the club/team onRaceResult.custom1/2. Points are awarded to the entity, not to individual riders. -
School-as-entity results — where a school (not a rider) is the scored subject.
-
Materialised standings — the aggregation output the sync layer writes back as participant-less
RaceResultrows keyed by the grouping CustomListValue.
-
Aggregation therefore resolves each grouping slot with a coalesce over a LEFT JOIN to the
EventParticipant — EP first, RaceResult.custom_* as the fallback:
LEFT JOIN event_participant ep ON ep.id = race_result.event_participant_id
...
COALESCE(ep.custom_1_id, race_result.custom_1_id) AS club_id
COALESCE(ep.custom_2_id, race_result.custom_2_id) AS team_id
One rule serves both shapes: participant-linked rows read the always-current EP; participant-less
rows (which have no EP) fall through to RaceResult.custom_*. The LEFT JOIN (not INNER) keeps
the participant-less rows in the result set.
|
This supersedes a transitional design. An earlier implementation read grouping only from
|
5. Scoring: PointsCalculator
A PointsCalculator maps a finishing position to points for a given series. It is a pure
function:
public interface PointsCalculator {
int getPoints(Integer position);
default String name() { return getClass().getSimpleName(); }
}
Two scales ship today, each matching a league’s published rulebook:
| Calculator | Scale |
|---|---|
|
Deep non-linear curve: 1→250, 2→200, 3–9 → |
|
Shallow truncated scale, positions 1–12: |
5.1. Selecting the scale
PointsCalculatorFactory resolves a calculator from the value of
Leaderboard.pointsCalculatorClass (or the results-import pointsCalculator query parameter):
by short code, then by fully-qualified class name via the Spring context, falling back to no-arg
reflection. A null/blank/unresolvable value returns the default
(SASchoolCyclingSeriesPointsCalculator).
|
Scoring happens at import time. |
6. Classification & aggregation: LeaderboardType
A LeaderboardCalculator aggregates scored results into standings. The strategy is selected by
the leaderboard’s LeaderboardType. Each calculator filters results to the board’s
categories (and optional gender / levelFilter), groups them by the board’s subject, sums
points, and ranks.
| Type | Code | Aggregation strategy |
|---|---|---|
|
S |
Group by school ( |
|
T |
As SCHOOL, but per subject count only the best |
|
A |
Cross-gender aggregate of top schools — combines the male and female school totals into one ranking. |
|
I |
Group by |
The shared base (AbstractLeaderboardCalculator) applies the common filters: the gender filter
(skip when gender is null) and the level filter (match the result’s custom1 against
Leaderboard.levelFilter, resolved EP-first per Classification source: EventParticipant vs RaceResult), and resolves the
scoring scale via the factory.
|
Type reuse over schema churn. |
6.1. Level filtering (PS vs HS)
Sub-classifications such as Primary School vs High School are not hardcoded category-ID lists.
The board’s levelFilter references the PS or HS CustomListValue, and LeaderboardCategory
links the board to its series categories. Two boards (one PS, one HS) over the same series
differ only by levelFilter.
7. Worked shape
The two production leagues instantiate this model as follows — full setup steps in their procedures:
| League | Scoring | Boards (LeaderboardType) |
Grouped by |
|---|---|---|---|
|
SCHOOL / TOP_SCHOOLS / ALL_STARS (+ INDIVIDUAL_RIDER), per PS/HS |
School ( |
|
|
INDIVIDUAL_RIDER (Individual), SCHOOL reused (Team/Club), All Rounder |
Person; Club ( |
8. Current state & compromises
The model above is the to-be target. These parts are knowingly not there yet; each is tracked
in design journal 2026-06/leaderboard-model-to-be.adoc.
| Area | Compromise carried today |
|---|---|
Population |
Standings are aggregated by manual SQL, not by the |
Classification source |
The to-be |
Per-leaderboard event set |
A leaderboard’s event scope (e.g. "all events except the TTT", "starred events only") is not
modelled — |
Catch-all entity exclusion |
The "None"/"Other" catch-all club exclusion is hardcoded CustomListValue ids in each query.
The to-be is a |
Grouping type |
|
9. Related Documentation
-
Leaderboard Synchronization — the deferred automated-recalculation layer that builds on this model.
-
Results Import Design — how results (and their points) enter the system.
-
Import Operations Guide — the operator steps for importing results with a chosen calculator.
-
design-journal/2026-06/leaderboard-model-to-be.adoc— the to-be design decisions (EP-authoritative classification, calculator build, event set, CLV metadata) and the compromises tracked above.