Canonical golf skills migration + copy participants feature (#369)
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:
parent
48b1d470f1
commit
21dfe3627c
16 changed files with 6687 additions and 98 deletions
105
app/models/__tests__/canonical-golf-skills.test.ts
Normal file
105
app/models/__tests__/canonical-golf-skills.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
165
app/models/__tests__/golf-skills.test.ts
Normal file
165
app/models/__tests__/golf-skills.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
251
app/models/__tests__/season-participant-copy.test.ts
Normal file
251
app/models/__tests__/season-participant-copy.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
42
app/models/canonical-golf-skills.ts
Normal file
42
app/models/canonical-golf-skills.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ export * from "./tournament";
|
|||
export * from "./participant";
|
||||
export * from "./tournament-result";
|
||||
export * from "./canonical-surface-elo";
|
||||
export * from "./canonical-golf-skills";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ export async function cloneSportsSeason(
|
|||
name: p.name,
|
||||
shortName: p.shortName,
|
||||
externalId: p.externalId,
|
||||
participantId: p.participantId,
|
||||
}))
|
||||
)
|
||||
.returning()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
80
drizzle/0091_fast_inhumans.sql
Normal file
80
drizzle/0091_fast_inhumans.sql
Normal 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;
|
||||
5702
drizzle/meta/0091_snapshot.json
Normal file
5702
drizzle/meta/0091_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -638,6 +638,13 @@
|
|||
"when": 1777677410094,
|
||||
"tag": "0090_powerful_redwing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 91,
|
||||
"version": "7",
|
||||
"when": 1777700829535,
|
||||
"tag": "0091_fast_inhumans",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue