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:

Automated recalculation (Leaderboard Sync) is deferred. Today, standings are aggregated manually (SQL or admin tooling) by reading RaceResult.points. This model is what makes that possible: as long as the points are scored correctly on import and the leaderboard configuration is in place, standings can be produced at any time.

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
  1. Scoring (PointsCalculator) converts a finishing position into points using the series' scoring scale. Points are written to RaceResult.points at result-import time.

  2. 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’s LeaderboardType.

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

name

String(100)

Display name.

type

LeaderboardType

Aggregation strategy — see Classification & aggregation: LeaderboardType.

gender

Gender (nullable)

Optional gender filter; NULL = gender-neutral (e.g. when the category structure already splits men/women).

topCount

Integer

For TOP_SCHOOLS-style boards: how many best results per subject to count.

resultLimit

Integer

Max entries shown in the standings.

active

Boolean

Whether the board is live.

pointsCalculatorClass

String(255)

The scoring scale — short code or fully-qualified PointsCalculator class. See Scoring: PointsCalculator.

series

Series (required)

The series this board scores.

levelFilter

CustomListValue

Optional sub-classification filter (e.g. PS vs HS) — see Level filtering (PS vs HS).

groupByList

CustomList

The CustomList whose values the board groups by (e.g. Schools, Clubs).

categories

Set<LeaderboardCategory>

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

leaderboard

Leaderboard (required)

Owning board.

seriesCategory

EventCategory (required)

A series-level category that feeds the board.

pointsMultiplier

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. A Leaderboard belongs to exactly one series; its categories (EventCategory rows with series set) are the series-level categories that LeaderboardCategory references.

  • ResultSet — a set of results for one event/category/state. Carries the state (ResultSetState), an optional leaderboard link, and (for the sync layer) an errorMessage.

  • RaceResult — one rider’s result within a ResultSet. The fields the model depends on:

    Field Role in the model

    position

    Input to scoring (PointsCalculator).

    points

    Output of scoring; the value aggregation sums.

    seq

    Deterministic ordering key (positions can tie); also a secondary upsert key.

    status

    ResultStatus — FINISHED / DNS / DNF / RELEGATED / DQ / LAPPED. Non-finishers are scored per series rules.

    person, eventParticipant

    The rider; the EP carries the grouping CustomListValues.

    custom1, custom2, custom3

    CustomListValue slots used for grouping (school, club, team — see Classification & aggregation: LeaderboardType).

4. 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

SASchoolCyclingSeriesPointsCalculator
(the default)

Deep non-linear curve: 1→250, 2→200, 3–9 → 160 − (pos−3)·10, 10–13 → 80 + (13−pos)·5, 14–38 → 30 + (38−pos)·2, 39–67 → 1 + (67−pos), 68+ → 1.

WpcaRoadLeaguePointsCalculator

Shallow truncated scale, positions 1–12: 50, 45, 40, 36, 34, 32, 30, 28, 26, 24, 22, 20; position 13+ → 2 participation points.

4.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. RaceResult.points is set when results are imported, using the selected calculator. Choosing the wrong calculator on import writes wrong points; the fix is to re-import the event’s results with the correct pointsCalculator. The operator-facing steps are in Import Operations Guide.

5. 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

SCHOOL

S

Group by school (EventParticipant.custom3); sum all points per school. Reused for any single-CustomList grouping — e.g. Club or Team (see note below).

TOP_SCHOOLS

T

As SCHOOL, but per subject count only the best topCount results before summing.

ALL_STARS

A

Cross-gender aggregate of top schools — combines the male and female school totals into one ranking.

INDIVIDUAL_RIDER

I

Group by Person across all events in the series; sum the rider’s points season-wide.

The shared base (AbstractLeaderboardCalculator) applies the common filters: the gender filter (skip when gender is null) and the level filter (match RaceResult.custom1 against Leaderboard.levelFilter), and resolves the scoring scale via the factory.

Type reuse over schema churn. SCHOOL aggregates by a single CustomList value on the EventParticipant, which is exactly the mechanic a Club or Team board needs. Rather than add new enum values, a club/team board reuses SCHOOL and points groupByList at the relevant CustomList. A more general GROUP / CUSTOM_LIST type — and a COALESCE(team, club) scoring key for team overlays — is tracked as deferred work in Leaderboard Sync. The WP Cycling procedure documents the current Team/Club overlay in concrete terms.

5.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.

6. Worked shape

The two production leagues instantiate this model as follows — full setup steps in their procedures:

League Scoring Boards (LeaderboardType) Grouped by

WC Schools Cycling

SASchoolCyclingSeriesPointsCalculator

SCHOOL / TOP_SCHOOLS / ALL_STARS (+ INDIVIDUAL_RIDER), per PS/HS levelFilter

School (custom3)

WP Cycling Road League

WpcaRoadLeaguePointsCalculator

INDIVIDUAL_RIDER (Individual), SCHOOL reused (Team/Club), All Rounder

Person; Club (custom1) / Team (custom2)