The simulator now reads surface Elo from the canonical table (participant_surface_elos). The existing per-window admin page still posts to batchUpsertSurfaceElos; without this change, those edits would silently fail to affect simulator output. Mirror every write to both tables until Phase 4 removes the per-window path entirely. Phase 3 Task 5 (partial — canonical write path; dedicated canonical admin UI deferred; existing page continues to work and now edits both tables). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
/**
|
|
* 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, seasonParticipantSurfaceElos, 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;
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
export async function getSurfaceElosForSeason(
|
|
sportsSeasonId: string
|
|
): Promise<SurfaceEloWithName[]> {
|
|
const db = database();
|
|
const rows = 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,
|
|
participantName: seasonParticipants.name,
|
|
})
|
|
.from(seasonParticipantSurfaceElos)
|
|
.innerJoin(seasonParticipants, eq(seasonParticipantSurfaceElos.participantId, seasonParticipants.id))
|
|
.where(eq(seasonParticipantSurfaceElos.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.
|
|
*/
|
|
export async function batchUpsertSurfaceElos(
|
|
inputs: SurfaceEloInput[]
|
|
): Promise<void> {
|
|
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.
|
|
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`,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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).
|
|
*/
|
|
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,
|
|
}]));
|
|
}
|