/** * Model for Participant Surface Elos * * Manages surface-specific Elo ratings for tennis (and future surface-based sports). * Each participant in a sports season can have separate Elo ratings for hard, * clay, and grass courts. */ import { database } from "~/database/context"; import { participantSurfaceElos, participants } from "~/database/schema"; import { eq, 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; } /** * Get all surface Elo records for a sports season, joined with participant names. * Returns one record per participant (participants with no Elo record are excluded). */ export async function getSurfaceElosForSeason( sportsSeasonId: string ): Promise { const db = database(); const rows = await db .select({ id: participantSurfaceElos.id, participantId: participantSurfaceElos.participantId, sportsSeasonId: participantSurfaceElos.sportsSeasonId, worldRanking: participantSurfaceElos.worldRanking, eloHard: participantSurfaceElos.eloHard, eloClay: participantSurfaceElos.eloClay, eloGrass: participantSurfaceElos.eloGrass, updatedAt: participantSurfaceElos.updatedAt, participantName: participants.name, }) .from(participantSurfaceElos) .innerJoin(participants, eq(participantSurfaceElos.participantId, participants.id)) .where(eq(participantSurfaceElos.sportsSeasonId, sportsSeasonId)) .orderBy(participants.name); return rows; } /** * Upsert surface Elo ratings for a batch of participants. * Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are * overwritten atomically — the admin always submits all three values. */ export async function batchUpsertSurfaceElos( inputs: SurfaceEloInput[] ): Promise { if (inputs.length === 0) return; const db = database(); const now = new Date(); await db .insert(participantSurfaceElos) .values( inputs.map(({ participantId, sportsSeasonId, worldRanking, eloHard, eloClay, eloGrass }) => ({ participantId, sportsSeasonId, worldRanking: worldRanking ?? null, eloHard: eloHard ?? null, eloClay: eloClay ?? null, eloGrass: eloGrass ?? null, updatedAt: now, })) ) .onConflictDoUpdate({ target: [participantSurfaceElos.participantId, participantSurfaceElos.sportsSeasonId], 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`, }, }); } /** * Returns a Map from participantId to surface Elos for use in the simulator. * Participants with no record are absent from the map (simulator falls back to 1500). */ export async function getSurfaceEloMap( sportsSeasonId: string ): Promise> { const db = database(); const rows = await db .select({ participantId: participantSurfaceElos.participantId, worldRanking: participantSurfaceElos.worldRanking, eloHard: participantSurfaceElos.eloHard, eloClay: participantSurfaceElos.eloClay, eloGrass: participantSurfaceElos.eloGrass, }) .from(participantSurfaceElos) .where(eq(participantSurfaceElos.sportsSeasonId, sportsSeasonId)); return new Map(rows.map((r) => [r.participantId, { worldRanking: r.worldRanking, eloHard: r.eloHard, eloClay: r.eloClay, eloGrass: r.eloGrass, }])); }