refactor(surface-elo): canonical-only; drop per-window path
batchUpsertSurfaceElos now writes only to the canonical participant_surface_elos table (the mirror step is now the only step). getSurfaceElosForSeason joins through season_participants → canonical participants so the admin UI keeps its existing (id, participantId, sportsSeasonId, ...) row shape. season_participants without a canonical link get silently skipped. In practice every qualifying-points roster entry is canonical-linked after Phase 2 backfill + Phase 3 auto-linking on createParticipant. Phase 4 Tasks 2 + 3 of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5732afd93b
commit
549b437466
2 changed files with 61 additions and 98 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SurfaceEloWithName[]> {
|
||||
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<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.
|
||||
// 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<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> {
|
||||
const db = database();
|
||||
const rows = await db
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue