Canonical golf skills migration + copy participants feature

Migrate participant_golf_skills from per-season to canonical table (one
row per real-world player), mirroring the existing participant_surface_elos
pattern. Adds admin UI to copy participants between same-sport seasons with
EV stubs, transactional writes, and comprehensive tests.
This commit is contained in:
Chris Parsons 2026-05-02 09:47:54 -07:00
parent 48b1d470f1
commit cedeb844e3
16 changed files with 6687 additions and 98 deletions

View file

@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import {
upsertCanonicalGolfSkills,
getCanonicalGolfSkills,
} from "../canonical-golf-skills";
import { database } from "~/database/context";
const PARTICIPANT_ID = "participant-1";
const SAMPLE_SKILLS = {
id: "skill-1",
participantId: PARTICIPANT_ID,
sgTotal: "2.50",
datagolfRank: 3,
mastersOdds: 800,
usOpenOdds: 1200,
openChampionshipOdds: 1000,
pgaChampionshipOdds: 900,
updatedAt: new Date("2026-01-28T00:00:00Z"),
};
function makeUpsertDb(returnValue: object) {
return {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([returnValue]),
}),
}),
}),
};
}
function makeSelectDb(rows: object[]) {
return {
query: {
participantGolfSkills: {
findFirst: vi.fn().mockResolvedValue(rows[0] ?? null),
},
},
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("upsertCanonicalGolfSkills", () => {
it("inserts or updates golf skill data", async () => {
vi.mocked(database).mockReturnValue(makeUpsertDb(SAMPLE_SKILLS) as never);
const result = await upsertCanonicalGolfSkills({
participantId: PARTICIPANT_ID,
sgTotal: "2.50",
datagolfRank: 3,
mastersOdds: 800,
});
expect(result).toEqual(SAMPLE_SKILLS);
});
it("throws if participantId is missing", async () => {
await expect(
upsertCanonicalGolfSkills({ participantId: undefined as unknown as string }),
).rejects.toThrow("participantId is required");
});
it("uses onConflictDoUpdate on participantId", async () => {
const mockDb = makeUpsertDb(SAMPLE_SKILLS);
vi.mocked(database).mockReturnValue(mockDb as never);
await upsertCanonicalGolfSkills({
participantId: PARTICIPANT_ID,
sgTotal: "2.50",
});
const onConflictCall = (mockDb.insert as ReturnType<typeof vi.fn>)
.mock.results[0].value.values.mock.results[0].value.onConflictDoUpdate;
expect(onConflictCall).toHaveBeenCalled();
});
});
describe("getCanonicalGolfSkills", () => {
it("returns skills when found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([SAMPLE_SKILLS]) as never);
const result = await getCanonicalGolfSkills(PARTICIPANT_ID);
expect(result).toEqual(SAMPLE_SKILLS);
});
it("returns null when not found", async () => {
vi.mocked(database).mockReturnValue(makeSelectDb([]) as never);
const result = await getCanonicalGolfSkills("nonexistent");
expect(result).toBeNull();
});
});

View file

@ -36,13 +36,11 @@ function makeUpsertDb(returnValue: object) {
function makeSelectDb(rows: object[]) {
return {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
}),
}),
}),
query: {
participantSurfaceElos: {
findFirst: vi.fn().mockResolvedValue(rows[0] ?? null),
},
},
};
}

View file

@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { database } from "~/database/context";
import { getGolfSkillsForSeason, getGolfSkillsMap, batchUpsertGolfSkills } from "../golf-skills";
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockReturnThis(),
};
beforeEach(() => {
vi.clearAllMocks();
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
mockDb.select.mockReturnValue(mockDb);
mockDb.from.mockReturnValue(mockDb);
mockDb.where.mockReturnValue(mockDb);
mockDb.innerJoin.mockReturnValue(mockDb);
mockDb.leftJoin.mockReturnValue(mockDb);
mockDb.orderBy.mockReturnValue(mockDb);
mockDb.insert.mockReturnValue(mockDb);
mockDb.values.mockReturnValue(mockDb);
mockDb.onConflictDoUpdate.mockResolvedValue(undefined);
});
describe("getGolfSkillsMap", () => {
it("returns an empty Map when no records exist", async () => {
mockDb.where.mockResolvedValue([]);
const result = await getGolfSkillsMap("season-1");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it("returns a Map keyed by seasonParticipantId with sgTotal converted to number", async () => {
mockDb.where.mockResolvedValue([
{
seasonParticipantId: "sp-1",
sportsSeasonId: "season-1",
id: "skill-1",
sgTotal: "2.50",
datagolfRank: 3,
mastersOdds: 800,
usOpenOdds: null,
openChampionshipOdds: null,
pgaChampionshipOdds: null,
updatedAt: new Date("2026-01-01"),
},
]);
const result = await getGolfSkillsMap("season-1");
expect(result.size).toBe(1);
const record = result.get("sp-1");
expect(record).toBeDefined();
expect(record?.sgTotal).toBe(2.5);
expect(record?.participantId).toBe("sp-1");
expect(record?.mastersOdds).toBe(800);
});
it("preserves null sgTotal", async () => {
mockDb.where.mockResolvedValue([
{
seasonParticipantId: "sp-1",
sportsSeasonId: "season-1",
id: "skill-1",
sgTotal: null,
datagolfRank: null,
mastersOdds: null,
usOpenOdds: null,
openChampionshipOdds: null,
pgaChampionshipOdds: null,
updatedAt: new Date("2026-01-01"),
},
]);
const result = await getGolfSkillsMap("season-1");
expect(result.get("sp-1")?.sgTotal).toBeNull();
});
});
describe("getGolfSkillsForSeason", () => {
it("returns records joined with participant names and converts sgTotal", async () => {
mockDb.orderBy.mockResolvedValue([
{
id: "skill-1",
participantId: "sp-1",
sportsSeasonId: "season-1",
sgTotal: "1.24",
datagolfRank: 10,
mastersOdds: 400,
usOpenOdds: null,
openChampionshipOdds: null,
pgaChampionshipOdds: null,
updatedAt: new Date("2026-01-01"),
participantName: "Rory McIlroy",
},
]);
const result = await getGolfSkillsForSeason("season-1");
expect(result).toHaveLength(1);
expect(result[0].participantName).toBe("Rory McIlroy");
expect(result[0].sgTotal).toBe(1.24);
expect(result[0].mastersOdds).toBe(400);
});
it("returns empty array when no records exist", async () => {
mockDb.orderBy.mockResolvedValue([]);
const result = await getGolfSkillsForSeason("season-1");
expect(result).toEqual([]);
});
});
describe("batchUpsertGolfSkills", () => {
it("does nothing when given an empty array", async () => {
await batchUpsertGolfSkills([]);
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("writes canonical rows using resolved canonical participant ids", async () => {
mockDb.where.mockResolvedValueOnce([
{ id: "sp-1", canonicalId: "canon-1" },
{ id: "sp-2", canonicalId: "canon-2" },
]);
await batchUpsertGolfSkills([
{ participantId: "sp-1", sgTotal: 2.5, mastersOdds: 400 },
{ participantId: "sp-2", sgTotal: null, usOpenOdds: 800 },
]);
expect(mockDb.insert).toHaveBeenCalledOnce();
expect(mockDb.values).toHaveBeenCalledOnce();
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
const passedValues = mockDb.values.mock.calls[0][0] as Array<{
participantId: string;
sgTotal: string | null;
mastersOdds: number | null;
usOpenOdds: number | null;
}>;
expect(passedValues).toHaveLength(2);
expect(passedValues[0].participantId).toBe("canon-1");
expect(passedValues[0].sgTotal).toBe("2.5");
expect(passedValues[0].mastersOdds).toBe(400);
expect(passedValues[1].participantId).toBe("canon-2");
expect(passedValues[1].sgTotal).toBeNull();
expect(passedValues[1].usOpenOdds).toBe(800);
});
it("skips season_participants that have no canonical link", async () => {
mockDb.where.mockResolvedValueOnce([]);
await batchUpsertGolfSkills([
{ participantId: "sp-1", sgTotal: 2.5 },
]);
expect(mockDb.insert).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/database/schema", () => ({
seasonParticipants: { id: "sp.id", sportsSeasonId: "sp.sports_season_id", name: "sp.name", shortName: "sp.short_name", externalId: "sp.external_id", participantId: "sp.participant_id" },
seasonParticipantExpectedValues: { sportsSeasonId: "pev.sports_season_id" },
}));
vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
inArray: vi.fn(),
count: vi.fn(),
and: (...args: unknown[]) => ({ type: "and", args }),
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings, values }),
asc: (col: unknown) => ({ type: "asc", col }),
desc: (col: unknown) => ({ type: "desc", col }),
}));
import * as schema from "~/database/schema";
import { copyParticipantsFromSeason } from "../season-participant";
import { database } from "~/database/context";
const SOURCE_ID = "source-season";
const TARGET_ID = "target-season";
function makeSourceParticipant(overrides: Record<string, unknown> = {}) {
return {
id: "sp-src-1",
sportsSeasonId: SOURCE_ID,
name: "Tiger Woods",
shortName: "T. Woods",
externalId: "tiger",
participantId: "canon-1",
...overrides,
};
}
function makeSourceEv(overrides: Record<string, unknown> = {}) {
return {
id: "ev-src-1",
participantId: "sp-src-1",
sportsSeasonId: SOURCE_ID,
sourceOdds: 800,
sourceElo: null,
source: "futures",
worldRanking: 5,
...overrides,
};
}
function makeMockDb({
targetParticipants = [] as object[],
sourceParticipants = [] as object[],
sourceEvRows = [] as object[],
newParticipantRows = sourceParticipants.map((p, i) => ({
...(p as Record<string, unknown>),
id: `sp-new-${i + 1}`,
sportsSeasonId: TARGET_ID,
})),
}: {
targetParticipants?: object[];
sourceParticipants?: object[];
sourceEvRows?: object[];
newParticipantRows?: object[];
} = {}) {
const insertedRows: Record<string, unknown> = {};
const findManyCalls: { table: string; args: unknown }[] = [];
const db = {
query: {
seasonParticipants: {
findMany: vi.fn().mockImplementation((opts: unknown) => {
findManyCalls.push({ table: "seasonParticipants", args: opts });
const where = (opts as Record<string, unknown>)?.where as { val: string } | undefined;
if (where?.val === SOURCE_ID) return sourceParticipants;
if (where?.val === TARGET_ID) return targetParticipants;
return targetParticipants;
}),
},
seasonParticipantExpectedValues: {
findMany: vi.fn().mockResolvedValue(sourceEvRows),
},
},
insert: vi.fn().mockImplementation((table: object) => {
let key: string;
let returnRows: object[];
if (table === schema.seasonParticipants) {
key = "seasonParticipants";
returnRows = newParticipantRows;
} else {
key = "participantExpectedValues";
returnRows = [];
}
return {
values: vi.fn().mockImplementation((vals: unknown) => {
insertedRows[key] = vals;
if (key === "seasonParticipants") {
return { returning: vi.fn().mockResolvedValue(returnRows) };
}
return Promise.resolve(undefined);
}),
};
}),
transaction: vi.fn().mockImplementation(async (fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
_insertedRows: insertedRows,
_findManyCalls: findManyCalls,
};
return db;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("copyParticipantsFromSeason", () => {
it("returns empty result when source has no participants", async () => {
const db = makeMockDb({ sourceParticipants: [] });
vi.mocked(database).mockReturnValue(db as never);
const result = await copyParticipantsFromSeason(SOURCE_ID, TARGET_ID);
expect(result).toEqual({ copied: 0, skipped: 0, evStubsCopied: 0 });
expect(db.transaction).not.toHaveBeenCalled();
});
it("skips participants already present in target (case-insensitive)", async () => {
const db = makeMockDb({
targetParticipants: [makeSourceParticipant({ id: "sp-tgt-1", sportsSeasonId: TARGET_ID })],
sourceParticipants: [makeSourceParticipant()],
});
vi.mocked(database).mockReturnValue(db as never);
const result = await copyParticipantsFromSeason(SOURCE_ID, TARGET_ID);
expect(result).toEqual({ copied: 0, skipped: 1, evStubsCopied: 0 });
expect(db.transaction).not.toHaveBeenCalled();
});
it("copies participants and returns counts", async () => {
const srcP1 = makeSourceParticipant({ name: "Tiger Woods", externalId: "tiger", participantId: "canon-1" });
const srcP2 = makeSourceParticipant({ id: "sp-src-2", name: "Rory McIlroy", externalId: "rory", participantId: "canon-2" });
const db = makeMockDb({
sourceParticipants: [srcP1, srcP2],
newParticipantRows: [
{ ...srcP1, id: "sp-new-1", sportsSeasonId: TARGET_ID },
{ ...srcP2, id: "sp-new-2", sportsSeasonId: TARGET_ID },
],
});
vi.mocked(database).mockReturnValue(db as never);
const result = await copyParticipantsFromSeason(SOURCE_ID, TARGET_ID);
expect(result.copied).toBe(2);
expect(result.skipped).toBe(0);
expect(db._insertedRows.seasonParticipants).toHaveLength(2);
const values = db._insertedRows.seasonParticipants as Array<Record<string, unknown>>;
expect(values[0]).toMatchObject({ name: "Tiger Woods", externalId: "tiger", participantId: "canon-1" });
expect(values[1]).toMatchObject({ name: "Rory McIlroy", externalId: "rory", participantId: "canon-2" });
});
it("copies EV stubs matched by externalId", async () => {
const srcP = makeSourceParticipant({ externalId: "tiger" });
const srcEv = makeSourceEv({ participantId: "sp-src-1", sourceOdds: 800 });
const db = makeMockDb({
sourceParticipants: [srcP],
sourceEvRows: [srcEv],
newParticipantRows: [{ ...srcP, id: "sp-new-1", sportsSeasonId: TARGET_ID }],
});
vi.mocked(database).mockReturnValue(db as never);
const result = await copyParticipantsFromSeason(SOURCE_ID, TARGET_ID);
expect(result.evStubsCopied).toBe(1);
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
expect(evRows).toHaveLength(1);
expect(evRows[0]).toMatchObject({
participantId: "sp-new-1",
sourceOdds: 800,
source: "futures",
worldRanking: 5,
});
});
it("copies EV stubs matched by name when externalId is null", async () => {
const srcP = makeSourceParticipant({ externalId: null, name: "Tiger Woods" });
const srcEv = makeSourceEv({ participantId: "sp-src-1" });
const db = makeMockDb({
sourceParticipants: [srcP],
sourceEvRows: [srcEv],
newParticipantRows: [{ ...srcP, id: "sp-new-1", sportsSeasonId: TARGET_ID }],
});
vi.mocked(database).mockReturnValue(db as never);
const result = await copyParticipantsFromSeason(SOURCE_ID, TARGET_ID);
expect(result.evStubsCopied).toBe(1);
});
it("skips EV rows with neither sourceOdds nor sourceElo", async () => {
const srcP = makeSourceParticipant();
const srcEv = makeSourceEv({ sourceOdds: null, sourceElo: null });
const db = makeMockDb({
sourceParticipants: [srcP],
sourceEvRows: [srcEv],
newParticipantRows: [{ ...srcP, id: "sp-new-1", sportsSeasonId: TARGET_ID }],
});
vi.mocked(database).mockReturnValue(db as never);
const result = await copyParticipantsFromSeason(SOURCE_ID, TARGET_ID);
expect(result.evStubsCopied).toBe(0);
expect(db._insertedRows.participantExpectedValues).toBeUndefined();
});
it("wraps writes in a transaction", async () => {
const srcP = makeSourceParticipant();
const db = makeMockDb({
sourceParticipants: [srcP],
newParticipantRows: [{ ...srcP, id: "sp-new-1", sportsSeasonId: TARGET_ID }],
});
vi.mocked(database).mockReturnValue(db as never);
await copyParticipantsFromSeason(SOURCE_ID, TARGET_ID);
expect(db.transaction).toHaveBeenCalledOnce();
});
it("handles partial duplicates (some copied, some skipped)", async () => {
const srcP1 = makeSourceParticipant({ name: "Tiger Woods" });
const srcP2 = makeSourceParticipant({ id: "sp-src-2", name: "Rory McIlroy", participantId: "canon-2" });
const db = makeMockDb({
targetParticipants: [makeSourceParticipant({ id: "sp-tgt-1", sportsSeasonId: TARGET_ID, name: "Tiger Woods" })],
sourceParticipants: [srcP1, srcP2],
newParticipantRows: [{ ...srcP2, id: "sp-new-1", sportsSeasonId: TARGET_ID }],
});
vi.mocked(database).mockReturnValue(db as never);
const result = await copyParticipantsFromSeason(SOURCE_ID, TARGET_ID);
expect(result.copied).toBe(1);
expect(result.skipped).toBe(1);
const values = db._insertedRows.seasonParticipants as Array<Record<string, unknown>>;
expect(values).toHaveLength(1);
expect(values[0]).toMatchObject({ name: "Rory McIlroy" });
});
});

View file

@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export type CanonicalGolfSkills = typeof schema.participantGolfSkills.$inferSelect;
export type NewCanonicalGolfSkills = typeof schema.participantGolfSkills.$inferInsert;
export async function upsertCanonicalGolfSkills(
data: NewCanonicalGolfSkills,
): Promise<CanonicalGolfSkills> {
if (!data.participantId) {
throw new Error("participantId is required");
}
const db = database();
const [result] = await db
.insert(schema.participantGolfSkills)
.values(data)
.onConflictDoUpdate({
target: [schema.participantGolfSkills.participantId],
set: {
sgTotal: data.sgTotal,
datagolfRank: data.datagolfRank,
mastersOdds: data.mastersOdds,
usOpenOdds: data.usOpenOdds,
openChampionshipOdds: data.openChampionshipOdds,
pgaChampionshipOdds: data.pgaChampionshipOdds,
updatedAt: new Date(),
},
})
.returning();
return result;
}
export async function getCanonicalGolfSkills(
participantId: string,
): Promise<CanonicalGolfSkills | null> {
const db = database();
return await db.query.participantGolfSkills.findFirst({
where: eq(schema.participantGolfSkills.participantId, participantId),
}) ?? null;
}

View file

@ -34,10 +34,7 @@ export async function getCanonicalSurfaceElo(
participantId: string
): Promise<CanonicalSurfaceElo | null> {
const db = database();
const results = await db
.select()
.from(schema.participantSurfaceElos)
.where(eq(schema.participantSurfaceElos.participantId, participantId))
.limit(1);
return results[0] ?? null;
return await db.query.participantSurfaceElos.findFirst({
where: eq(schema.participantSurfaceElos.participantId, participantId),
}) ?? null;
}

View file

@ -1,14 +1,18 @@
/**
* Model for Participant Golf Skills
* Golf Skills model (canonical).
*
* Manages golf-specific skill data for qualifying-points golf seasons.
* Primary metric: SG: Total (strokes gained per round vs. field average).
* Optional per-major American odds allow major-specific probability blending.
* Golf skills are stored in the canonical `participant_golf_skills` table,
* keyed by canonical participant id. The admin UI still works in terms of
* season_participants we join through season_participants canonical
* participant canonical golf skills so the UI doesn't need to know about
* the canonical layer.
*
* Mirrors the surface-elo.ts pattern exactly.
*/
import { database } from "~/database/context";
import { participantGolfSkills, seasonParticipants } from "~/database/schema";
import { eq, sql } from "drizzle-orm";
import { eq, inArray, sql } from "drizzle-orm";
export interface GolfSkillsRecord {
id: string;
@ -29,7 +33,6 @@ export interface GolfSkillsWithName extends GolfSkillsRecord {
export interface GolfSkillsInput {
participantId: string;
sportsSeasonId: string;
sgTotal?: number | null;
datagolfRank?: number | null;
mastersOdds?: number | null;
@ -39,18 +42,22 @@ export interface GolfSkillsInput {
}
/**
* Get all golf skill records for a sports season, joined with participant names.
* Returns one record per participant (seasonParticipants with no record are excluded).
* Load golf skills for a sports season's roster, joined with the per-window
* participant's name. Internally joins season_participants canonical
* participants canonical participant_golf_skills.
*
* The returned `id` is the canonical `participant_golf_skills.id`;
* `participantId` is the season_participant id (admin UI keys rows by that).
*/
export async function getGolfSkillsForSeason(
sportsSeasonId: string
sportsSeasonId: string,
): Promise<GolfSkillsWithName[]> {
const db = database();
const rows = await db
.select({
id: participantGolfSkills.id,
participantId: participantGolfSkills.participantId,
sportsSeasonId: participantGolfSkills.sportsSeasonId,
participantId: seasonParticipants.id,
sportsSeasonId: seasonParticipants.sportsSeasonId,
sgTotal: participantGolfSkills.sgTotal,
datagolfRank: participantGolfSkills.datagolfRank,
mastersOdds: participantGolfSkills.mastersOdds,
@ -60,77 +67,114 @@ export async function getGolfSkillsForSeason(
updatedAt: participantGolfSkills.updatedAt,
participantName: seasonParticipants.name,
})
.from(participantGolfSkills)
.innerJoin(seasonParticipants, eq(participantGolfSkills.participantId, seasonParticipants.id))
.where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId))
.from(seasonParticipants)
.leftJoin(
participantGolfSkills,
eq(participantGolfSkills.participantId, seasonParticipants.participantId),
)
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId))
.orderBy(seasonParticipants.name);
return rows.map((r) => ({
...r,
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
}));
return rows
.filter((r): r is typeof r & { id: string; updatedAt: Date } => r.id !== null)
.map((r) => ({
...r,
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
})) as GolfSkillsWithName[];
}
/**
* Returns a Map from participantId GolfSkillsRecord for use in the simulator.
* Participants with no record are absent from the map (simulator falls back to SG = 0).
* Returns a Map from season_participant.id GolfSkillsRecord for use in the simulator.
* Internally joins through canonical participants to read from the canonical table.
* Participants with no canonical link or no canonical golf skills record are absent
* from the map (simulator falls back to SG = 0).
*/
export async function getGolfSkillsMap(
sportsSeasonId: string
sportsSeasonId: string,
): Promise<Map<string, GolfSkillsRecord>> {
const db = database();
const rows = await db
.select()
.from(participantGolfSkills)
.where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId));
.select({
seasonParticipantId: seasonParticipants.id,
sportsSeasonId: seasonParticipants.sportsSeasonId,
id: participantGolfSkills.id,
sgTotal: participantGolfSkills.sgTotal,
datagolfRank: participantGolfSkills.datagolfRank,
mastersOdds: participantGolfSkills.mastersOdds,
usOpenOdds: participantGolfSkills.usOpenOdds,
openChampionshipOdds: participantGolfSkills.openChampionshipOdds,
pgaChampionshipOdds: participantGolfSkills.pgaChampionshipOdds,
updatedAt: participantGolfSkills.updatedAt,
})
.from(seasonParticipants)
.innerJoin(
participantGolfSkills,
eq(participantGolfSkills.participantId, seasonParticipants.participantId),
)
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId));
return new Map(rows.map((r) => [
r.participantId,
r.seasonParticipantId,
{
...r,
id: r.id,
participantId: r.seasonParticipantId,
sportsSeasonId: r.sportsSeasonId,
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
datagolfRank: r.datagolfRank,
mastersOdds: r.mastersOdds,
usOpenOdds: r.usOpenOdds,
openChampionshipOdds: r.openChampionshipOdds,
pgaChampionshipOdds: r.pgaChampionshipOdds,
updatedAt: r.updatedAt,
},
]));
}
/**
* Upsert golf skill ratings for a batch of seasonParticipants.
* Uses INSERT ON CONFLICT DO UPDATE so all columns are overwritten atomically.
* Upsert golf skill ratings for a batch of season_participants. Writes to the
* canonical `participant_golf_skills` 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 via
* the auto-linking on createParticipant.
*/
export async function batchUpsertGolfSkills(
inputs: GolfSkillsInput[]
inputs: GolfSkillsInput[],
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
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),
sgTotal: i.sgTotal !== null && i.sgTotal !== undefined ? String(i.sgTotal) : null,
datagolfRank: i.datagolfRank ?? null,
mastersOdds: i.mastersOdds ?? null,
usOpenOdds: i.usOpenOdds ?? null,
openChampionshipOdds: i.openChampionshipOdds ?? null,
pgaChampionshipOdds: i.pgaChampionshipOdds ?? null,
}))
.filter((r): r is typeof r & { canonicalId: string } => typeof r.canonicalId === "string");
if (canonicalRows.length === 0) return;
await db
.insert(participantGolfSkills)
.values(
inputs.map(({
participantId,
sportsSeasonId,
sgTotal,
datagolfRank,
mastersOdds,
usOpenOdds,
openChampionshipOdds,
pgaChampionshipOdds,
}) => ({
participantId,
sportsSeasonId,
// decimal columns are stored/passed as strings in Drizzle
sgTotal: sgTotal !== null && sgTotal !== undefined ? String(sgTotal) : null,
datagolfRank: datagolfRank ?? null,
mastersOdds: mastersOdds ?? null,
usOpenOdds: usOpenOdds ?? null,
openChampionshipOdds: openChampionshipOdds ?? null,
pgaChampionshipOdds: pgaChampionshipOdds ?? null,
updatedAt: now,
}))
)
.values(canonicalRows.map(({ canonicalId, ...rest }) => ({
participantId: canonicalId,
...rest,
updatedAt: now,
})))
.onConflictDoUpdate({
target: [participantGolfSkills.participantId, participantGolfSkills.sportsSeasonId],
target: [participantGolfSkills.participantId],
set: {
sgTotal: sql`excluded.sg_total`,
datagolfRank: sql`excluded.datagolf_rank`,

View file

@ -22,3 +22,4 @@ export * from "./tournament";
export * from "./participant";
export * from "./tournament-result";
export * from "./canonical-surface-elo";
export * from "./canonical-golf-skills";

View file

@ -170,27 +170,95 @@ export async function deleteParticipant(id: string): Promise<void> {
await db.delete(schema.seasonParticipants).where(eq(schema.seasonParticipants.id, id));
}
export interface CopyParticipantsResult {
copied: number;
skipped: number;
evStubsCopied: number;
}
/**
* Copy participants from one sports season to another, skipping duplicates by
* name. Also copies EV stubs (sourceOdds, sourceElo, worldRanking) so that
* running a simulation immediately after copying produces differentiated
* results.
*
* Canonical skill data (golf SG:Total, tennis surface Elos) is automatically
* available through the canonical participant link no copying needed.
*/
export async function copyParticipantsFromSeason(
sourceSportsSeasonId: string,
targetSportsSeasonId: string
): Promise<Participant[]> {
// Get all participants from source season
const sourceParticipants = await findParticipantsBySportsSeasonId(sourceSportsSeasonId);
targetSportsSeasonId: string,
): Promise<CopyParticipantsResult> {
const db = database();
// Insert them into the target season
if (sourceParticipants.length === 0) {
return [];
const targetParticipants = await findParticipantsBySportsSeasonId(targetSportsSeasonId);
const targetNames = new Set(targetParticipants.map((p) => p.name.toLowerCase()));
const sourceParticipants = await findParticipantsBySportsSeasonId(sourceSportsSeasonId);
const toCopy = sourceParticipants.filter((p) => !targetNames.has(p.name.toLowerCase()));
if (toCopy.length === 0) {
return { copied: 0, skipped: sourceParticipants.length, evStubsCopied: 0 };
}
return await createManyParticipants(
sourceParticipants.map((p) => ({
sportsSeasonId: targetSportsSeasonId,
name: p.name,
shortName: p.shortName,
externalId: p.externalId,
expectedValue: p.expectedValue,
}))
);
const sourceEvRows = await db.query.seasonParticipantExpectedValues.findMany({
where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sourceSportsSeasonId),
});
const sourceEvByKey = new Map(sourceEvRows.filter((r) => r.sourceOdds !== null || r.sourceElo !== null).map((r) => {
const sp = sourceParticipants.find((p) => p.id === r.participantId);
return sp ? [sp.externalId ?? sp.name, r] as const : null;
}).filter((x): x is NonNullable<typeof x> => x !== null));
return await db.transaction(async (tx) => {
const newParticipants = await tx
.insert(schema.seasonParticipants)
.values(
toCopy.map((p) => ({
sportsSeasonId: targetSportsSeasonId,
name: p.name,
shortName: p.shortName,
externalId: p.externalId,
participantId: p.participantId,
})),
)
.returning();
let evStubsCopied = 0;
if (sourceEvByKey.size > 0 && newParticipants.length > 0) {
const now = new Date();
const evRows = newParticipants
.map((newP) => {
const key = newP.externalId ?? newP.name;
const sourceEv = sourceEvByKey.get(key);
if (!sourceEv) return null;
return {
participantId: newP.id,
sportsSeasonId: targetSportsSeasonId,
probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0",
probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0",
expectedValue: "0",
source: sourceEv.source,
sourceOdds: sourceEv.sourceOdds,
sourceElo: sourceEv.sourceElo,
worldRanking: sourceEv.worldRanking,
calculatedAt: now,
updatedAt: now,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (evRows.length > 0) {
await tx.insert(schema.seasonParticipantExpectedValues).values(evRows);
evStubsCopied = evRows.length;
}
}
return {
copied: newParticipants.length,
skipped: sourceParticipants.length - toCopy.length,
evStubsCopied,
};
});
}
/**

View file

@ -258,6 +258,7 @@ export async function cloneSportsSeason(
name: p.name,
shortName: p.shortName,
externalId: p.externalId,
participantId: p.participantId,
}))
)
.returning()

View file

@ -120,7 +120,6 @@ export async function action({ request, params }: Route.ActionArgs) {
const skillInputs = participants
.map((p) => ({
participantId: p.id,
sportsSeasonId,
sgTotal: parseDecimalOrNull(formData.get(`sgTotal_${p.id}`) as string),
datagolfRank: parseIntOrNull(formData.get(`datagolfRank_${p.id}`) as string),
mastersOdds: parseIntOrNull(formData.get(`mastersOdds_${p.id}`) as string),

View file

@ -3,13 +3,14 @@ import { Form, Link } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.participants";
import { logger } from "~/lib/logger";
import { findSportsSeasonById } from "~/models/sports-season";
import { findSportsSeasonById, findSportsSeasonsBySportId } from "~/models/sports-season";
import {
findParticipantsBySportsSeasonId,
findParticipantByName,
createParticipant,
deleteParticipant,
updateParticipant,
copyParticipantsFromSeason,
} from "~/models/season-participant";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
@ -30,7 +31,14 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Plus, Trash2, ArrowLeft, Pencil, Check, X } from "lucide-react";
import { Plus, Trash2, ArrowLeft, Pencil, Check, X, Copy } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Participants — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
@ -45,10 +53,15 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(params.id);
const sameSportSeasons = (await findSportsSeasonsBySportId(sportsSeason.sportId))
.filter((s) => s.id !== params.id)
.toSorted((a, b) => b.year - a.year);
// Type assertion since we know the sport relation is included
return {
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
participants
participants,
sameSportSeasons,
};
}
@ -173,6 +186,35 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
if (intent === "copy-from-season") {
const sourceSeasonId = formData.get("sourceSeasonId");
if (typeof sourceSeasonId !== "string" || !sourceSeasonId) {
return { error: "Please select a source season.", intent: "copy-from-season" };
}
const targetSeason = await findSportsSeasonById(params.id);
if (!targetSeason) {
return { error: "Target season not found.", intent: "copy-from-season" };
}
const sourceSeason = await findSportsSeasonById(sourceSeasonId);
if (!sourceSeason) {
return { error: "Source season not found.", intent: "copy-from-season" };
}
if (sourceSeason.sportId !== targetSeason.sportId) {
return { error: "Source season must be the same sport.", intent: "copy-from-season" };
}
try {
const result = await copyParticipantsFromSeason(sourceSeasonId, params.id);
return { success: true, intent: "copy-from-season", copyResult: result };
} catch (error) {
logger.error("Error copying participants:", error);
return { error: "Failed to copy participants. Please try again.", intent: "copy-from-season" };
}
}
// Add single participant
const name = formData.get("name");
@ -203,8 +245,9 @@ export async function action({ request, params }: Route.ActionArgs) {
}
export default function ManageParticipants({ loaderData, actionData }: Route.ComponentProps) {
const { sportsSeason, participants } = loaderData;
const { sportsSeason, participants, sameSportSeasons } = loaderData;
const [editing, setEditing] = useState<{ id: string; name: string; externalId: string } | null>(null);
const [sourceSeasonId, setSourceSeasonId] = useState("");
const cancelEdit = () => setEditing(null);
@ -326,6 +369,60 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
</Card>
</div>
{sameSportSeasons.length > 0 && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Copy from Another Season</CardTitle>
<CardDescription>
Copy participants and their EV data (odds, Elo, rankings) from an
existing {sportsSeason.sport.name} season. Duplicates are skipped by name.
Golf skills and tennis surface Elos are shared automatically via canonical
player records.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4" key={`copy-${formKey}`}>
<input type="hidden" name="intent" value="copy-from-season" />
<div className="flex gap-4 items-end">
<div className="flex-1 space-y-2">
<Label htmlFor="sourceSeasonId">Source Season</Label>
<Select name="sourceSeasonId" value={sourceSeasonId} onValueChange={setSourceSeasonId}>
<SelectTrigger>
<SelectValue placeholder="Select a season to copy from" />
</SelectTrigger>
<SelectContent>
{sameSportSeasons.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({s.year}) {s.status}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={!sourceSeasonId}>
<Copy className="mr-2 h-4 w-4" />
Copy Participants
</Button>
</div>
{actionData?.error && actionData?.intent === "copy-from-season" && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && actionData?.intent === "copy-from-season" && actionData.copyResult && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
{actionData.copyResult.copied} participant{actionData.copyResult.copied !== 1 ? "s" : ""} copied
{actionData.copyResult.skipped > 0 && `, ${actionData.copyResult.skipped} skipped (already exist)`}
{actionData.copyResult.evStubsCopied > 0 && `${actionData.copyResult.evStubsCopied} EV stubs (odds/Elo) copied`}
</div>
)}
</Form>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>All Participants</CardTitle>

View file

@ -936,6 +936,10 @@ export const participantsRelations = relations(participants, ({ one, many }) =>
fields: [participants.id],
references: [participantSurfaceElos.participantId],
}),
golfSkills: one(participantGolfSkills, {
fields: [participants.id],
references: [participantGolfSkills.participantId],
}),
}));
export const tournamentsRelations = relations(tournaments, ({ one, many }) => ({
@ -1381,13 +1385,44 @@ export const pendingStandingsMappingsRelations = relations(pendingStandingsMappi
// (defined above). The per-window `season_participant_surface_elos` table
// was dropped in Phase 4 of the canonical tournament layer migration.
// ─── Participant Golf Skills ───────────────────────────────────────────────────
// Stores golf-specific skill data for qualifying-points golf seasons.
// ─── Participant Golf Skills (Canonical) ──────────────────────────────────────
// Canonical golf skill data: one row per real-world player, shared across all
// sports season windows (matching the participant_surface_elos pattern).
//
// Primary metric: SG: Total (strokes gained per round vs. field average).
// Optional per-major American odds allow major-specific probability blending.
// One row per (participantId, sportsSeasonId).
//
// The per-window `season_participant_golf_skills` table (previously named
// `participant_golf_skills`) was the old per-season version. It will be dropped
// after this migration is confirmed working.
// TODO: Drop `season_participant_golf_skills` table and remove its schema +
// relations once the canonical migration is confirmed working.
export const participantGolfSkills = pgTable("participant_golf_skills", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
sgTotal: decimal("sg_total", { precision: 5, scale: 2 }),
datagolfRank: integer("datagolf_rank"),
mastersOdds: integer("masters_odds"),
usOpenOdds: integer("us_open_odds"),
openChampionshipOdds: integer("open_championship_odds"),
pgaChampionshipOdds: integer("pga_championship_odds"),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => ({
uniqueParticipant: uniqueIndex("participant_golf_skills_participant_unique")
.on(t.participantId),
}));
export const participantGolfSkillsRelations = relations(participantGolfSkills, ({ one }) => ({
participant: one(participants, {
fields: [participantGolfSkills.participantId],
references: [participants.id],
}),
}));
export const seasonParticipantGolfSkills = pgTable("season_participant_golf_skills", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
@ -1395,28 +1430,25 @@ export const participantGolfSkills = pgTable("participant_golf_skills", {
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
// Strokes gained total per round vs. field (e.g. +2.1 means 2.1 strokes/round better than avg)
sgTotal: decimal("sg_total", { precision: 5, scale: 2 }),
// DataGolf / OWGR rank for reference and admin ordering
datagolfRank: integer("datagolf_rank"),
// Per-major American odds (e.g. +400 = 20% implied win prob). All nullable.
mastersOdds: integer("masters_odds"),
usOpenOdds: integer("us_open_odds"),
openChampionshipOdds: integer("open_championship_odds"),
pgaChampionshipOdds: integer("pga_championship_odds"),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => ({
uniqueParticipantSeason: uniqueIndex("participant_golf_skills_unique")
uniqueParticipantSeason: uniqueIndex("season_participant_golf_skills_unique")
.on(t.participantId, t.sportsSeasonId),
}));
export const participantGolfSkillsRelations = relations(participantGolfSkills, ({ one }) => ({
export const seasonParticipantGolfSkillsRelations = relations(seasonParticipantGolfSkills, ({ one }) => ({
participant: one(seasonParticipants, {
fields: [participantGolfSkills.participantId],
fields: [seasonParticipantGolfSkills.participantId],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantGolfSkills.sportsSeasonId],
fields: [seasonParticipantGolfSkills.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));

View file

@ -0,0 +1,80 @@
-- Step 1: Create the backup per-season table
CREATE TABLE IF NOT EXISTS "season_participant_golf_skills" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"participant_id" uuid NOT NULL,
"sports_season_id" uuid NOT NULL,
"sg_total" numeric(5, 2),
"datagolf_rank" integer,
"masters_odds" integer,
"us_open_odds" integer,
"open_championship_odds" integer,
"pga_championship_odds" integer,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
-- Step 2: Copy existing per-season data to backup table
INSERT INTO "season_participant_golf_skills" ("id", "participant_id", "sports_season_id", "sg_total", "datagolf_rank", "masters_odds", "us_open_odds", "open_championship_odds", "pga_championship_odds", "updated_at")
SELECT "id", "participant_id", "sports_season_id", "sg_total", "datagolf_rank", "masters_odds", "us_open_odds", "open_championship_odds", "pga_championship_odds", "updated_at"
FROM "participant_golf_skills";
--> statement-breakpoint
-- Step 3: Add FK constraints on backup table
DO $$ BEGIN
ALTER TABLE "season_participant_golf_skills" ADD CONSTRAINT "season_participant_golf_skills_participant_id_season_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."season_participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "season_participant_golf_skills" ADD CONSTRAINT "season_participant_golf_skills_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "season_participant_golf_skills_unique" ON "season_participant_golf_skills" USING btree ("participant_id","sports_season_id");
--> statement-breakpoint
-- Step 4 + 5: Clear and repurpose participant_golf_skills as canonical table.
-- Wrapped in a transaction so that a failure between TRUNCATE and re-INSERT
-- doesn't leave the table empty. The backup table from steps 1-3 remains as a
-- safety net regardless.
BEGIN;
--> statement-breakpoint
ALTER TABLE "participant_golf_skills" DROP CONSTRAINT IF EXISTS "participant_golf_skills_participant_id_season_participants_id_fk";
--> statement-breakpoint
ALTER TABLE "participant_golf_skills" DROP CONSTRAINT IF EXISTS "participant_golf_skills_sports_season_id_sports_seasons_id_fk";
--> statement-breakpoint
DROP INDEX IF EXISTS "participant_golf_skills_unique";
--> statement-breakpoint
ALTER TABLE "participant_golf_skills" DROP COLUMN IF EXISTS "sports_season_id";
--> statement-breakpoint
TRUNCATE "participant_golf_skills";
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "participant_golf_skills" ADD CONSTRAINT "participant_golf_skills_participant_id_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."participants"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "participant_golf_skills_participant_unique" ON "participant_golf_skills" USING btree ("participant_id");
--> statement-breakpoint
-- Migrate data from per-season to canonical, taking latest values per canonical participant
INSERT INTO "participant_golf_skills" ("id", "participant_id", "sg_total", "datagolf_rank", "masters_odds", "us_open_odds", "open_championship_odds", "pga_championship_odds", "updated_at")
SELECT DISTINCT ON (sp.participant_id)
gen_random_uuid(),
sp.participant_id,
backup.sg_total,
backup.datagolf_rank,
backup.masters_odds,
backup.us_open_odds,
backup.open_championship_odds,
backup.pga_championship_odds,
backup.updated_at
FROM "season_participant_golf_skills" backup
JOIN "season_participants" sp ON backup.participant_id = sp.id
WHERE sp.participant_id IS NOT NULL
ORDER BY sp.participant_id, backup.updated_at DESC;
--> statement-breakpoint
COMMIT;

File diff suppressed because it is too large Load diff

View file

@ -638,6 +638,13 @@
"when": 1777677410094,
"tag": "0090_powerful_redwing",
"breakpoints": true
},
{
"idx": 91,
"version": "7",
"when": 1777700829535,
"tag": "0091_fast_inhumans",
"breakpoints": true
}
]
}
}