diff --git a/app/models/__tests__/surface-elo.test.ts b/app/models/__tests__/surface-elo.test.ts index 017543b..2db14a9 100644 --- a/app/models/__tests__/surface-elo.test.ts +++ b/app/models/__tests__/surface-elo.test.ts @@ -72,20 +72,21 @@ describe("batchUpsertSurfaceElos", () => { expect(mockDb.insert).not.toHaveBeenCalled(); }); - it("calls insert with the correct values on the per-window table", async () => { - // The function first upserts to the per-window table, then reads - // season_participants for canonical-id lookup. Make the follow-up select - // return empty so the canonical write path short-circuits and we only - // observe the per-window insert in this assertion. - mockDb.where.mockResolvedValue([]); + it("writes canonical rows using resolved canonical participant ids", async () => { + // season_participants lookup returns canonical links for p1 and p2. + mockDb.where.mockResolvedValue([ + { id: "p1", canonicalId: "canon-1" }, + { id: "p2", canonicalId: "canon-2" }, + ]); - const inputs = [ + await batchUpsertSurfaceElos([ { participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, { participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined }, - ]; - await batchUpsertSurfaceElos(inputs); + ]); + expect(mockDb.insert).toHaveBeenCalledOnce(); expect(mockDb.values).toHaveBeenCalledOnce(); + expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce(); const passedValues = mockDb.values.mock.calls[0][0] as Array<{ participantId: string; @@ -94,42 +95,21 @@ describe("batchUpsertSurfaceElos", () => { eloGrass: number | null; }>; expect(passedValues).toHaveLength(2); - expect(passedValues[0].participantId).toBe("p1"); + expect(passedValues[0].participantId).toBe("canon-1"); expect(passedValues[0].eloHard).toBe(1800); + expect(passedValues[1].participantId).toBe("canon-2"); expect(passedValues[1].eloHard).toBeNull(); expect(passedValues[1].eloGrass).toBeNull(); // undefined → null }); - it("uses onConflictDoUpdate for upsert behaviour", async () => { - mockDb.where.mockResolvedValue([]); // no canonical links; only per-window write - - await batchUpsertSurfaceElos([ - { participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 }, - ]); - expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce(); - }); - - it("mirrors writes to canonical participant_surface_elos when linked", async () => { - // season_participants lookup returns canonical links for p1 only. - mockDb.where.mockResolvedValue([ - { id: "p1", canonicalId: "canon-1" }, - ]); + it("skips season_participants that have no canonical link", async () => { + mockDb.where.mockResolvedValue([]); // no canonical links; short-circuits. await batchUpsertSurfaceElos([ { participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 }, ]); - // One per-window insert, one canonical insert → two inserts total. - expect(mockDb.insert).toHaveBeenCalledTimes(2); - expect(mockDb.onConflictDoUpdate).toHaveBeenCalledTimes(2); - - const canonicalValues = mockDb.values.mock.calls[1][0] as Array<{ - participantId: string; - eloHard: number | null; - }>; - expect(canonicalValues).toHaveLength(1); - expect(canonicalValues[0].participantId).toBe("canon-1"); - expect(canonicalValues[0].eloHard).toBe(2000); + expect(mockDb.insert).not.toHaveBeenCalled(); }); }); diff --git a/app/models/surface-elo.ts b/app/models/surface-elo.ts index 83258c2..b7d903c 100644 --- a/app/models/surface-elo.ts +++ b/app/models/surface-elo.ts @@ -1,13 +1,17 @@ /** - * Model for Participant Surface Elos + * Surface Elo model (canonical). * - * 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. + * 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, seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema"; +import { participantSurfaceElos, seasonParticipants } from "~/database/schema"; import { eq, inArray, sql } from "drizzle-orm"; export type CourtSurface = "hard" | "clay" | "grass"; @@ -37,72 +41,55 @@ export interface SurfaceEloInput { } /** - * Get all surface Elo records for a sports season, joined with participant names. - * Returns one record per participant (seasonParticipants with no Elo record are excluded). + * 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 + sportsSeasonId: string, ): Promise { const db = database(); - const rows = await db + return await db .select({ - id: seasonParticipantSurfaceElos.id, - participantId: seasonParticipantSurfaceElos.participantId, - sportsSeasonId: seasonParticipantSurfaceElos.sportsSeasonId, - worldRanking: seasonParticipantSurfaceElos.worldRanking, - eloHard: seasonParticipantSurfaceElos.eloHard, - eloClay: seasonParticipantSurfaceElos.eloClay, - eloGrass: seasonParticipantSurfaceElos.eloGrass, - updatedAt: seasonParticipantSurfaceElos.updatedAt, + 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(seasonParticipantSurfaceElos) - .innerJoin(seasonParticipants, eq(seasonParticipantSurfaceElos.participantId, seasonParticipants.id)) - .where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId)) + .from(seasonParticipants) + .innerJoin( + participantSurfaceElos, + eq(participantSurfaceElos.participantId, seasonParticipants.participantId), + ) + .where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId)) .orderBy(seasonParticipants.name); - - return rows; } /** - * Upsert surface Elo ratings for a batch of seasonParticipants. - * Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are - * overwritten atomically — the admin always submits all three values. + * 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[] + inputs: SurfaceEloInput[], ): Promise { if (inputs.length === 0) return; const db = database(); const now = new Date(); - await db - .insert(seasonParticipantSurfaceElos) - .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: [seasonParticipantSurfaceElos.participantId, seasonParticipantSurfaceElos.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`, - }, - }); - - // Mirror writes to the canonical participant_surface_elos table. - // The simulator reads from canonical, so per-window-only edits would - // not affect simulations. Remove the per-window path entirely in Phase 4. + // Resolve canonical ids for every season_participant in the batch. const sps = await db .select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId }) .from(seasonParticipants) @@ -141,20 +128,16 @@ export async function batchUpsertSurfaceElos( } /** - * 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). - */ -/** - * Build a map keyed by seasonParticipant.id → surface Elo values, sourced from - * the canonical `participant_surface_elos` table. Joins seasonParticipants → - * canonical participants → canonical surfaceElo. + * 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 simply absent from the map — callers should - * handle `undefined` for such cases (as the tennis simulator does). + * 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 + sportsSeasonId: string, ): Promise> { const db = database(); const rows = await db