brackt/app/models/surface-elo.ts

165 lines
5.6 KiB
TypeScript
Raw Normal View History

/**
* 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<SurfaceEloWithName[]> {
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<void> {
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<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> {
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,
}]));
}