/** * Surface Elo model (canonical). * * Surface Elos are stored in the canonical `participant_surface_elos` table, * keyed by canonical participant id. The admin UI still works in terms of * season_participants — we join through season_participants → canonical * participant → canonical surface Elo so the UI doesn't need to know about * the canonical layer. * * See `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md`. */ import { database } from "~/database/context"; import { participantSurfaceElos, seasonParticipants } from "~/database/schema"; import { eq, inArray, sql } from "drizzle-orm"; export type CourtSurface = "hard" | "clay" | "grass"; export interface SurfaceEloRecord { id: string; participantId: string; sportsSeasonId: string; worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null; updatedAt: Date; } export interface SurfaceEloWithName extends SurfaceEloRecord { participantName: string; } export interface SurfaceEloInput { participantId: string; sportsSeasonId: string; worldRanking?: number | null; eloHard?: number | null; eloClay?: number | null; eloGrass?: number | null; } /** * Load surface Elos for a sports season's roster, joined with the per-window * participant's name. Internally joins season_participants → canonical * participants → canonical participant_surface_elos. * * The returned `id` is the canonical `participant_surface_elos.id`; * `participantId` is the season_participant id (admin UI keys rows by that). */ export async function getSurfaceElosForSeason( sportsSeasonId: string, ): Promise { const db = database(); return await db .select({ id: participantSurfaceElos.id, participantId: seasonParticipants.id, sportsSeasonId: seasonParticipants.sportsSeasonId, worldRanking: participantSurfaceElos.worldRanking, eloHard: participantSurfaceElos.eloHard, eloClay: participantSurfaceElos.eloClay, eloGrass: participantSurfaceElos.eloGrass, updatedAt: participantSurfaceElos.updatedAt, participantName: seasonParticipants.name, }) .from(seasonParticipants) .innerJoin( participantSurfaceElos, eq(participantSurfaceElos.participantId, seasonParticipants.participantId), ) .where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId)) .orderBy(seasonParticipants.name); } /** * Upsert surface Elos for a batch of season_participants. Writes to the * canonical `participant_surface_elos` table; callers pass season_participant * ids and we resolve canonical ids internally. * * season_participants without a canonical link are silently skipped. In * practice every qualifying-points roster entry is canonical-linked after * Phase 2 + the Phase 3 auto-linking on createParticipant. */ export async function batchUpsertSurfaceElos( inputs: SurfaceEloInput[], ): Promise { if (inputs.length === 0) return; const db = database(); const now = new Date(); // Resolve canonical ids for every season_participant in the batch. const sps = await db .select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId }) .from(seasonParticipants) .where(inArray(seasonParticipants.id, inputs.map((i) => i.participantId))); const canonicalByInputId = new Map(sps.map((sp) => [sp.id, sp.canonicalId])); const canonicalRows = inputs .map((i) => ({ canonicalId: canonicalByInputId.get(i.participantId), worldRanking: i.worldRanking ?? null, eloHard: i.eloHard ?? null, eloClay: i.eloClay ?? null, eloGrass: i.eloGrass ?? null, })) .filter((r): r is typeof r & { canonicalId: string } => typeof r.canonicalId === "string"); if (canonicalRows.length === 0) return; await db .insert(participantSurfaceElos) .values(canonicalRows.map(({ canonicalId, ...rest }) => ({ participantId: canonicalId, ...rest, updatedAt: now, }))) .onConflictDoUpdate({ target: [participantSurfaceElos.participantId], set: { worldRanking: sql`excluded.world_ranking`, eloHard: sql`excluded.elo_hard`, eloClay: sql`excluded.elo_clay`, eloGrass: sql`excluded.elo_grass`, updatedAt: sql`excluded.updated_at`, }, }); } /** * Build a map keyed by season_participant.id → surface Elo values, sourced * from the canonical `participant_surface_elos` table. Used by the tennis * simulator. * * Season participants without a linked canonical participant, or with no * canonical surface Elo row, are absent from the map — callers handle * `undefined` by falling back to 1500 (the simulator's default). */ export async function getSurfaceEloMap( sportsSeasonId: string, ): Promise> { const db = database(); const rows = await db .select({ seasonParticipantId: seasonParticipants.id, worldRanking: participantSurfaceElos.worldRanking, eloHard: participantSurfaceElos.eloHard, eloClay: participantSurfaceElos.eloClay, eloGrass: participantSurfaceElos.eloGrass, }) .from(seasonParticipants) .innerJoin( participantSurfaceElos, eq(participantSurfaceElos.participantId, seasonParticipants.participantId), ) .where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId)); return new Map(rows.map((r) => [r.seasonParticipantId, { worldRanking: r.worldRanking, eloHard: r.eloHard, eloClay: r.eloClay, eloGrass: r.eloGrass, }])); }