Files
gridpilot.gg/docs/standings-analysis.md
2026-01-21 16:52:43 +01:00

9.5 KiB

League Standings Calculation Analysis

Overview

This document analyzes the standings calculation logic for the GridPilot leagues feature. The system has two different standings entities:

  1. Standing (core/racing/domain/entities/Standing.ts) - League-level standings
  2. ChampionshipStanding (core/racing/domain/entities/championship/ChampionshipStanding.ts) - Season-level standings

Architecture Overview

Data Flow

Race Completion/Result Import
    ↓
CompleteRaceUseCase / ImportRaceResultsUseCase
    ↓
StandingRepository.recalculate() or StandingRepository.save()
    ↓
Standing Entity (League-level)
    ↓
GetLeagueStandingsUseCase
    ↓
LeagueStandingsPresenter
    ↓
API Response

Season-Level Standings Flow

Season Completion
    ↓
RecalculateChampionshipStandingsUseCase
    ↓
ChampionshipStanding Entity (Season-level)
    ↓
ChampionshipStandingRepository

Identified Issues

Issue 1: InMemoryStandingRepository.recalculate() Doesn't Consider Seasons

Location: adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts:169-270

Problem: The recalculate() method calculates standings from ALL completed races in a league, not just races from a specific season.

async recalculate(leagueId: string): Promise<Standing[]> {
  // ...
  const races = await this.raceRepository.findCompletedByLeagueId(leagueId);
  // ...
}

Impact: When standings are recalculated, they include results from all seasons, not just the current/active season. This means:

  • Historical season results are mixed with current season results
  • Standings don't accurately reflect a specific season's performance
  • Drop score policies can't be applied correctly per season

Expected Behavior: Standings should be calculated per season, considering only races from that specific season.

Issue 2: CompleteRaceUseCase Uses Hardcoded Points System

Location: core/racing/application/use-cases/CompleteRaceUseCase.ts:200-203

Problem: The updateStandings() method uses a hardcoded F1 points system instead of the league's configured points system.

standing = standing.addRaceResult(result.position.toNumber(), {
  1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
});

Impact:

  • All leagues use the same points system regardless of their configuration
  • Custom points systems are ignored
  • IndyCar and other racing series points systems are not applied

Expected Behavior: The points system should be retrieved from the league's configuration and applied accordingly.

Issue 3: ImportRaceResultsUseCase Recalculates All League Standings

Location: core/racing/application/use-cases/ImportRaceResultsUseCase.ts:156

Problem: When importing race results, the method calls standingRepository.recalculate(league.id.toString()) which recalculates ALL standings for the league.

await this.standingRepository.recalculate(league.id.toString());

Impact:

  • Performance issue: Recalculating all standings when only one race is imported
  • Standings include results from all seasons, not just the current season
  • Inefficient for large leagues with many races

Expected Behavior: Only recalculate standings for the specific season that the race belongs to.

Issue 4: Standing Entity Doesn't Track Season Association

Location: core/racing/domain/entities/Standing.ts

Problem: The Standing entity only tracks leagueId and driverId, but not seasonId.

export class Standing extends Entity<string> {
  readonly leagueId: LeagueId;
  readonly driverId: DriverId;
  // No seasonId field
}

Impact:

  • Standings are stored per league, not per season
  • Can't distinguish between standings from different seasons
  • Can't apply season-specific drop score policies

Expected Behavior: Standing should include a seasonId field to track which season the standings belong to.

Issue 5: GetLeagueStandingsUseCase Retrieves League-Level Standings

Location: core/racing/application/use-cases/GetLeagueStandingsUseCase.ts:39

Problem: The use case retrieves standings from StandingRepository.findByLeagueId(), which returns league-level standings.

const standings = await this.standingRepository.findByLeagueId(input.leagueId);

Impact:

  • Returns standings that include results from all seasons
  • Doesn't provide season-specific standings
  • Can't apply season-specific drop score policies

Expected Behavior: The use case should retrieve season-specific standings (ChampionshipStanding) or accept a seasonId parameter.

Issue 6: LeagueStandingsRepository is Not Connected to Standing Calculation

Location: adapters/racing/persistence/inmemory/InMemoryLeagueStandingsRepository.ts

Problem: The LeagueStandingsRepository interface is defined but not connected to the standing calculation logic.

export interface LeagueStandingsRepository {
  getLeagueStandings(leagueId: string): Promise<RawStanding[]>;
}

Impact:

  • The repository is not used by any standing calculation use case
  • It's only used for storing pre-calculated standings
  • No clear connection between standing calculation and this repository

Expected Behavior: The repository should be used to store and retrieve season-specific standings.

Issue 7: LeagueStandingsPresenter Hardcodes Metrics

Location: apps/api/src/domain/league/presenters/LeagueStandingsPresenter.ts:24-30

Problem: The presenter hardcodes wins, podiums, and races metrics to 0.

wins: 0,
podiums: 0,
races: 0,

Impact:

  • API always returns 0 for these metrics
  • Users can't see actual win/podium/race counts
  • TODO comment indicates this is a known issue

Expected Behavior: These metrics should be calculated and returned from the standing entity.

Root Cause Analysis

The core issue is a design inconsistency between two different standings systems:

  1. League-level standings (Standing entity):

    • Stored per league (not per season)
    • Updated immediately when races complete
    • Used by GetLeagueStandingsUseCase
    • Don't support season-specific features
  2. Season-level standings (ChampionshipStanding entity):

    • Stored per season
    • Calculated using RecalculateChampionshipStandingsUseCase
    • Support drop score policies per season
    • Not used by the main standings API

The system has two parallel standings calculation paths that don't align:

  • CompleteRaceUseCase and ImportRaceResultsUseCase update league-level standings
  • RecalculateChampionshipStandingsUseCase calculates season-level standings
  • GetLeagueStandingsUseCase retrieves league-level standings

Recommendations

Short-term Fixes

  1. Fix CompleteRaceUseCase to use league's points system

    • Retrieve points system from league configuration
    • Apply correct points based on league settings
  2. Fix ImportRaceResultsUseCase to recalculate only relevant standings

    • Pass seasonId to recalculate method
    • Only recalculate standings for the specific season
  3. Add seasonId to Standing entity

    • Modify Standing entity to include seasonId
    • Update all repositories and use cases to handle seasonId
  4. Fix GetLeagueStandingsUseCase to accept seasonId parameter

    • Add seasonId parameter to input
    • Filter standings by seasonId

Long-term Refactoring

  1. Consolidate standings into single system

    • Merge Standing and ChampionshipStanding entities
    • Use ChampionshipStanding as the primary standings entity
    • Remove league-level standings
  2. Update CompleteRaceUseCase to use RecalculateChampionshipStandingsUseCase

    • Instead of updating standings immediately, trigger recalculation
    • Ensure season-specific calculations
  3. Update ImportRaceResultsUseCase to use RecalculateChampionshipStandingsUseCase

    • Instead of calling StandingRepository.recalculate(), call RecalculateChampionshipStandingsUseCase
    • Ensure season-specific calculations
  4. Update GetLeagueStandingsUseCase to retrieve ChampionshipStanding

    • Change to retrieve season-specific standings
    • Add seasonId parameter to input
  5. Remove LeagueStandingsRepository

    • This repository is not connected to standing calculation
    • Use ChampionshipStandingRepository instead
  6. Fix LeagueStandingsPresenter to return actual metrics

    • Calculate wins, podiums, and races from standing entity
    • Remove hardcoded values

Testing Strategy

Unit Tests

  • Test CompleteRaceUseCase with different points systems
  • Test ImportRaceResultsUseCase with season-specific recalculation
  • Test GetLeagueStandingsUseCase with seasonId parameter
  • Test Standing entity with seasonId field

Integration Tests

  • Test complete race flow with season-specific standings
  • Test import race results flow with season-specific standings
  • Test standings recalculation for specific season
  • Test API endpoints with season-specific standings

End-to-End Tests

  • Test league standings API with season parameter
  • Test standings calculation across multiple seasons
  • Test drop score policy application per season

Conclusion

The standings calculation system has significant design issues that prevent accurate season-specific standings. The main problems are:

  1. League-level standings don't track seasons
  2. Points systems are hardcoded in some places
  3. Standings recalculation includes all seasons
  4. Two parallel standings systems don't align

Fixing these issues requires both short-term fixes and long-term refactoring to consolidate the standings system into a single, season-aware design.