Adds a Clone Season button on the admin sports season detail page. The clone form pre-fills all settings with the source season's data, bumping the year by one and shifting all dates accordingly. On submit, the new season is created in a single transaction that copies participants, scoring events (with dates shifted), futures odds/Elo rating stubs (zeroed-out probabilities so the simulator can re-run), and QP config for qualifying_points seasons. Fixes #282 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dc879e3033
commit
fa16581f9b
5 changed files with 925 additions and 26 deletions
411
app/models/__tests__/sports-season.clone.test.ts
Normal file
411
app/models/__tests__/sports-season.clone.test.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/database/schema", () => ({
|
||||
sportsSeasons: { id: "ss.id", sportId: "ss.sport_id" },
|
||||
participants: { sportsSeasonId: "p.sports_season_id" },
|
||||
scoringEvents: { sportsSeasonId: "se.sports_season_id" },
|
||||
participantExpectedValues: { sportsSeasonId: "pev.sports_season_id" },
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings, values }),
|
||||
lte: (col: unknown, val: unknown) => ({ type: "lte", col, val }),
|
||||
gte: (col: unknown, val: unknown) => ({ type: "gte", col, val }),
|
||||
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||
desc: (col: unknown) => ({ type: "desc", col }),
|
||||
asc: (col: unknown) => ({ type: "asc", col }),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/qualifying-points", () => ({
|
||||
getQPConfig: vi.fn(),
|
||||
updateQPConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as schema from "~/database/schema";
|
||||
import { cloneSportsSeason, type NewSportsSeason } from "../sports-season";
|
||||
import { database } from "~/database/context";
|
||||
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
|
||||
|
||||
const SOURCE_ID = "source-season-id";
|
||||
const NEW_ID = "new-season-id";
|
||||
|
||||
const sourceSeason = {
|
||||
id: SOURCE_ID,
|
||||
sportId: "sport-1",
|
||||
name: "2025 F1 Season",
|
||||
year: 2025,
|
||||
startDate: "2025-03-16",
|
||||
endDate: "2025-12-07",
|
||||
draftOn: "2025-01-01",
|
||||
draftOff: "2025-03-15",
|
||||
status: "completed",
|
||||
scoringType: "majors",
|
||||
scoringPattern: "season_standings",
|
||||
totalMajors: null,
|
||||
majorsCompleted: 5,
|
||||
qualifyingPointsFinalized: true,
|
||||
eloCalibrationExponent: "0.33",
|
||||
eloMinRating: 1250,
|
||||
eloMaxRating: 1750,
|
||||
simulationStatus: "idle",
|
||||
};
|
||||
|
||||
const newSeasonData: NewSportsSeason = {
|
||||
sportId: "sport-1",
|
||||
name: "2026 F1 Season",
|
||||
year: 2026,
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-12-07",
|
||||
draftOn: "2026-01-01",
|
||||
draftOff: "2026-03-15",
|
||||
status: "upcoming",
|
||||
simulationStatus: "idle",
|
||||
majorsCompleted: 0,
|
||||
qualifyingPointsFinalized: false,
|
||||
scoringType: "majors",
|
||||
scoringPattern: "season_standings",
|
||||
};
|
||||
|
||||
function makeMockDb({
|
||||
sourceSeason: ss = sourceSeason,
|
||||
participants = [] as object[],
|
||||
scoringEvents = [] as object[],
|
||||
sourceEvRows = [] as object[],
|
||||
insertedSeason = { ...newSeasonData, id: NEW_ID } as object,
|
||||
// New participants returned by the INSERT ... RETURNING — mirrors sources with new IDs by default
|
||||
newParticipantRows = (participants as Array<Record<string, unknown>>).map((p, i) => ({
|
||||
...p,
|
||||
id: `new-p${i + 1}`,
|
||||
sportsSeasonId: NEW_ID,
|
||||
})) as object[],
|
||||
} = {}) {
|
||||
// Track what was inserted into each table independently of call order
|
||||
const insertedRows: Record<string, unknown> = {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db: any = {
|
||||
query: {
|
||||
sportsSeasons: { findFirst: vi.fn().mockResolvedValue(ss) },
|
||||
participants: { findMany: vi.fn().mockResolvedValue(participants) },
|
||||
scoringEvents: { findMany: vi.fn().mockResolvedValue(scoringEvents) },
|
||||
participantExpectedValues: { findMany: vi.fn().mockResolvedValue(sourceEvRows) },
|
||||
},
|
||||
insert: vi.fn().mockImplementation((table: object) => {
|
||||
let key: string;
|
||||
let returnRows: object[];
|
||||
if (table === schema.sportsSeasons) {
|
||||
key = "sportsSeasons"; returnRows = [insertedSeason];
|
||||
} else if (table === schema.participants) {
|
||||
key = "participants"; returnRows = newParticipantRows;
|
||||
} else if (table === schema.scoringEvents) {
|
||||
key = "scoringEvents"; returnRows = [];
|
||||
} else {
|
||||
key = "participantExpectedValues"; returnRows = [];
|
||||
}
|
||||
return {
|
||||
values: vi.fn().mockImplementation((vals: unknown) => {
|
||||
insertedRows[key] = vals;
|
||||
return { returning: vi.fn().mockResolvedValue(returnRows) };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
// Passes `db` as the transaction object so tracked inserts still work
|
||||
transaction: vi.fn().mockImplementation(async (fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
||||
_insertedRows: insertedRows,
|
||||
};
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cloneSportsSeason", () => {
|
||||
it("creates new season with reset fields (status=upcoming, majorsCompleted=0, simulationStatus=idle)", async () => {
|
||||
const db = makeMockDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
expect(db._insertedRows.sportsSeasons).toMatchObject({
|
||||
name: "2026 F1 Season",
|
||||
year: 2026,
|
||||
status: "upcoming",
|
||||
simulationStatus: "idle",
|
||||
majorsCompleted: 0,
|
||||
qualifyingPointsFinalized: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges ELO calibration fields from source season", async () => {
|
||||
const db = makeMockDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
expect(db._insertedRows.sportsSeasons).toMatchObject({
|
||||
eloCalibrationExponent: "0.33",
|
||||
eloMinRating: 1250,
|
||||
eloMaxRating: 1750,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies participants with name, shortName, externalId — not expectedValue", async () => {
|
||||
const participants = [
|
||||
{ id: "p1", name: "Max Verstappen", shortName: "VER", externalId: "ext-1", expectedValue: "42.5" },
|
||||
{ id: "p2", name: "Lewis Hamilton", shortName: "HAM", externalId: "ext-2", expectedValue: "38.0" },
|
||||
];
|
||||
const db = makeMockDb({ participants });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
const rows = db._insertedRows.participants as object[];
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
sportsSeasonId: NEW_ID,
|
||||
name: "Max Verstappen",
|
||||
shortName: "VER",
|
||||
externalId: "ext-1",
|
||||
});
|
||||
expect(rows[0]).not.toHaveProperty("expectedValue");
|
||||
expect(rows[0]).not.toHaveProperty("id");
|
||||
});
|
||||
|
||||
it("skips participant insert when source has no participants", async () => {
|
||||
const db = makeMockDb({ participants: [] });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
expect(db._insertedRows.participants).toBeUndefined();
|
||||
});
|
||||
|
||||
it("copies scoring events with dates shifted by yearDelta", async () => {
|
||||
const scoringEvents = [
|
||||
{ id: "e1", name: "Bahrain GP", eventType: "final_standings", isQualifyingEvent: false,
|
||||
bracketTemplateId: null, scoringStartsAtRound: null,
|
||||
eventDate: "2025-03-16", eventStartsAt: null },
|
||||
{ id: "e2", name: "Abu Dhabi GP", eventType: "final_standings", isQualifyingEvent: false,
|
||||
bracketTemplateId: null, scoringStartsAtRound: null,
|
||||
eventDate: "2025-12-07", eventStartsAt: null },
|
||||
];
|
||||
const db = makeMockDb({ scoringEvents });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
const rows = db._insertedRows.scoringEvents as object[];
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
sportsSeasonId: NEW_ID,
|
||||
name: "Bahrain GP",
|
||||
eventDate: "2026-03-16",
|
||||
isComplete: false,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
name: "Abu Dhabi GP",
|
||||
eventDate: "2026-12-07",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves eventDate null when source event has no date", async () => {
|
||||
const scoringEvents = [
|
||||
{ id: "e1", name: "TBD Event", eventType: "schedule_event", isQualifyingEvent: false,
|
||||
bracketTemplateId: null, scoringStartsAtRound: null,
|
||||
eventDate: null, eventStartsAt: null },
|
||||
];
|
||||
const db = makeMockDb({ scoringEvents });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
const rows = db._insertedRows.scoringEvents as Array<{ eventDate: unknown }>;
|
||||
expect(rows[0].eventDate).toBeNull();
|
||||
});
|
||||
|
||||
it("shifts eventStartsAt timestamp by yearDelta", async () => {
|
||||
const originalDate = new Date("2025-06-15T14:00:00Z");
|
||||
const scoringEvents = [
|
||||
{ id: "e1", name: "Mid-year Event", eventType: "playoff_game", isQualifyingEvent: false,
|
||||
bracketTemplateId: null, scoringStartsAtRound: null,
|
||||
eventDate: null, eventStartsAt: originalDate },
|
||||
];
|
||||
const db = makeMockDb({ scoringEvents });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
const rows = db._insertedRows.scoringEvents as Array<{ eventStartsAt: Date }>;
|
||||
const shiftedDate = rows[0].eventStartsAt;
|
||||
expect(shiftedDate.getUTCFullYear()).toBe(2026);
|
||||
expect(shiftedDate.getUTCMonth()).toBe(originalDate.getUTCMonth());
|
||||
expect(shiftedDate.getUTCDate()).toBe(originalDate.getUTCDate());
|
||||
});
|
||||
|
||||
it("copies futures odds as stub EV records matched by externalId", async () => {
|
||||
const participants = [
|
||||
{ id: "src-p1", name: "Max Verstappen", shortName: "VER", externalId: "ext-1" },
|
||||
{ id: "src-p2", name: "Lewis Hamilton", shortName: "HAM", externalId: "ext-2" },
|
||||
];
|
||||
const sourceEvRows = [
|
||||
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
|
||||
source: "futures_odds", sourceOdds: -120, sourceElo: null, worldRanking: null },
|
||||
{ participantId: "src-p2", sportsSeasonId: SOURCE_ID,
|
||||
source: "futures_odds", sourceOdds: 300, sourceElo: null, worldRanking: null },
|
||||
];
|
||||
const newParticipantRows = [
|
||||
{ id: "new-p1", name: "Max Verstappen", externalId: "ext-1", sportsSeasonId: NEW_ID },
|
||||
{ id: "new-p2", name: "Lewis Hamilton", externalId: "ext-2", sportsSeasonId: NEW_ID },
|
||||
];
|
||||
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
|
||||
expect(evRows).toHaveLength(2);
|
||||
expect(evRows[0]).toMatchObject({
|
||||
participantId: "new-p1",
|
||||
sportsSeasonId: NEW_ID,
|
||||
source: "futures_odds",
|
||||
sourceOdds: -120,
|
||||
sourceElo: null,
|
||||
expectedValue: "0",
|
||||
probFirst: "0",
|
||||
});
|
||||
expect(evRows[1]).toMatchObject({ participantId: "new-p2", sourceOdds: 300 });
|
||||
});
|
||||
|
||||
it("copies Elo ratings and world rankings as stub EV records", async () => {
|
||||
const participants = [
|
||||
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
|
||||
];
|
||||
const sourceEvRows = [
|
||||
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
|
||||
source: "elo_simulation", sourceOdds: null, sourceElo: 1650, worldRanking: 3 },
|
||||
];
|
||||
const newParticipantRows = [
|
||||
{ id: "new-p1", name: "Player A", externalId: "ext-1", sportsSeasonId: NEW_ID },
|
||||
];
|
||||
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
|
||||
expect(evRows).toHaveLength(1);
|
||||
expect(evRows[0]).toMatchObject({
|
||||
participantId: "new-p1",
|
||||
source: "elo_simulation",
|
||||
sourceElo: 1650,
|
||||
worldRanking: 3,
|
||||
sourceOdds: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to name matching when externalId is null", async () => {
|
||||
const participants = [
|
||||
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: null },
|
||||
];
|
||||
const sourceEvRows = [
|
||||
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
|
||||
source: "futures_odds", sourceOdds: 200, sourceElo: null, worldRanking: null },
|
||||
];
|
||||
const newParticipantRows = [
|
||||
{ id: "new-p1", name: "Player A", externalId: null, sportsSeasonId: NEW_ID },
|
||||
];
|
||||
const db = makeMockDb({ participants, sourceEvRows, newParticipantRows });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
const evRows = db._insertedRows.participantExpectedValues as Array<Record<string, unknown>>;
|
||||
expect(evRows).toHaveLength(1);
|
||||
expect(evRows[0]).toMatchObject({ participantId: "new-p1", sourceOdds: 200 });
|
||||
});
|
||||
|
||||
it("skips EV copy when source has no odds or Elo data", async () => {
|
||||
const participants = [
|
||||
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
|
||||
];
|
||||
// EV row exists but has no input data — e.g. only calculated probabilities
|
||||
const sourceEvRows = [
|
||||
{ participantId: "src-p1", sportsSeasonId: SOURCE_ID,
|
||||
source: "manual", sourceOdds: null, sourceElo: null, worldRanking: null },
|
||||
];
|
||||
const db = makeMockDb({ participants, sourceEvRows });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
expect(db._insertedRows.participantExpectedValues).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips EV copy when source season has no EV rows", async () => {
|
||||
const participants = [
|
||||
{ id: "src-p1", name: "Player A", shortName: "PA", externalId: "ext-1" },
|
||||
];
|
||||
const db = makeMockDb({ participants, sourceEvRows: [] });
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
expect(db._insertedRows.participantExpectedValues).toBeUndefined();
|
||||
});
|
||||
|
||||
it("copies QP config when scoringPattern is qualifying_points", async () => {
|
||||
const qpSeason: NewSportsSeason = {
|
||||
...newSeasonData,
|
||||
scoringPattern: "qualifying_points",
|
||||
totalMajors: 4,
|
||||
};
|
||||
const mockQPConfig = [
|
||||
{ placement: 1, points: "20" },
|
||||
{ placement: 2, points: "14" },
|
||||
];
|
||||
vi.mocked(getQPConfig).mockResolvedValue(mockQPConfig as never);
|
||||
vi.mocked(updateQPConfig).mockResolvedValue([] as never);
|
||||
|
||||
const db = makeMockDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, qpSeason);
|
||||
|
||||
expect(getQPConfig).toHaveBeenCalledWith(SOURCE_ID, db);
|
||||
expect(updateQPConfig).toHaveBeenCalledWith(
|
||||
NEW_ID,
|
||||
[
|
||||
{ placement: 1, points: 20 },
|
||||
{ placement: 2, points: 14 },
|
||||
],
|
||||
db
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT copy QP config for non-QP seasons", async () => {
|
||||
const db = makeMockDb();
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await cloneSportsSeason(SOURCE_ID, newSeasonData);
|
||||
|
||||
expect(getQPConfig).not.toHaveBeenCalled();
|
||||
expect(updateQPConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when source season is not found", async () => {
|
||||
const db = makeMockDb();
|
||||
db.query.sportsSeasons.findFirst = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await expect(cloneSportsSeason("nonexistent-id", newSeasonData)).rejects.toThrow(
|
||||
"Source season nonexistent-id not found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { eq, sql, lte, gte } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { getQPConfig, updateQPConfig } from "~/models/qualifying-points";
|
||||
|
||||
export async function countSportsSeasons(): Promise<number> {
|
||||
const db = database();
|
||||
|
|
@ -103,7 +104,7 @@ export async function findAllSportsSeasons(): Promise<SportsSeasonListItem[]> {
|
|||
|
||||
const statusOrder: Record<SportsSeasonStatus, number> = { active: 0, upcoming: 1, completed: 2 };
|
||||
|
||||
return results.sort((a, b) => {
|
||||
return results.toSorted((a, b) => {
|
||||
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
|
||||
if (statusDiff !== 0) return statusDiff;
|
||||
const sportNameDiff = a.sport.name.localeCompare(b.sport.name);
|
||||
|
|
@ -153,27 +154,157 @@ export async function deleteSportsSeason(id: string): Promise<void> {
|
|||
await db.delete(schema.sportsSeasons).where(eq(schema.sportsSeasons.id, id));
|
||||
}
|
||||
|
||||
export async function copyParticipantsFromPreviousSeason(
|
||||
currentSeasonId: string,
|
||||
previousSeasonId: string
|
||||
): Promise<void> {
|
||||
export function shiftDateByYears(dateStr: string, years: number): string {
|
||||
const d = new Date(dateStr + "T12:00:00Z");
|
||||
d.setUTCFullYear(d.getUTCFullYear() + years);
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
function shiftTimestampByYears(date: Date, years: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setUTCFullYear(d.getUTCFullYear() + years);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a sports season: creates a new season from newData, then copies
|
||||
* participants (name/shortName/externalId only), scoring events (dates shifted
|
||||
* by yearDelta), futures odds + Elo ratings (as stubs for re-simulation),
|
||||
* and QP config (for qualifying_points seasons).
|
||||
*/
|
||||
export async function cloneSportsSeason(
|
||||
sourceId: string,
|
||||
newData: NewSportsSeason
|
||||
): Promise<SportsSeason> {
|
||||
const db = database();
|
||||
|
||||
// Get all participants from previous season
|
||||
const previousParticipants = await db.query.participants.findMany({
|
||||
where: eq(schema.participants.sportsSeasonId, previousSeasonId),
|
||||
|
||||
const sourceSeason = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, sourceId),
|
||||
});
|
||||
|
||||
// Insert them into the current season
|
||||
if (previousParticipants.length > 0) {
|
||||
await db.insert(schema.participants).values(
|
||||
previousParticipants.map((p) => ({
|
||||
sportsSeasonId: currentSeasonId,
|
||||
name: p.name,
|
||||
shortName: p.shortName,
|
||||
externalId: p.externalId,
|
||||
expectedValue: p.expectedValue,
|
||||
}))
|
||||
);
|
||||
if (!sourceSeason) {
|
||||
throw new Error(`Source season ${sourceId} not found`);
|
||||
}
|
||||
|
||||
const yearDelta = newData.year - sourceSeason.year;
|
||||
|
||||
// Merge ELO calibration fields from source (form doesn't expose them)
|
||||
const mergedData: NewSportsSeason = {
|
||||
eloCalibrationExponent: sourceSeason.eloCalibrationExponent,
|
||||
eloMinRating: sourceSeason.eloMinRating,
|
||||
eloMaxRating: sourceSeason.eloMaxRating,
|
||||
...newData,
|
||||
};
|
||||
|
||||
// Read all source data before writing anything
|
||||
const sourceParticipants = await db.query.participants.findMany({
|
||||
where: eq(schema.participants.sportsSeasonId, sourceId),
|
||||
});
|
||||
|
||||
const sourceEvents = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sourceId),
|
||||
});
|
||||
|
||||
const sourceQPConfig =
|
||||
newData.scoringPattern === "qualifying_points"
|
||||
? await getQPConfig(sourceId, db)
|
||||
: null;
|
||||
|
||||
// Read source futures odds and Elo ratings
|
||||
const sourceEvRows = await db.query.participantExpectedValues.findMany({
|
||||
where: eq(schema.participantExpectedValues.sportsSeasonId, sourceId),
|
||||
});
|
||||
|
||||
// Build lookup: (externalId ?? name) → source EV, only for rows that have
|
||||
// actual input data (odds or Elo) worth carrying forward
|
||||
type SourceEv = typeof sourceEvRows[number];
|
||||
const sourceEvById = new Map(sourceEvRows.map((r) => [r.participantId, r]));
|
||||
const sourceEvByKey = new Map<string, SourceEv>();
|
||||
for (const p of sourceParticipants) {
|
||||
const ev = sourceEvById.get(p.id);
|
||||
if (ev && (ev.sourceOdds !== null || ev.sourceElo !== null)) {
|
||||
sourceEvByKey.set(p.externalId ?? p.name, ev);
|
||||
}
|
||||
}
|
||||
|
||||
// All writes in a single transaction — partial clone is never committed
|
||||
return await db.transaction(async (tx: typeof db) => {
|
||||
const [newSeason] = await tx
|
||||
.insert(schema.sportsSeasons)
|
||||
.values(mergedData)
|
||||
.returning();
|
||||
|
||||
const newParticipants =
|
||||
sourceParticipants.length > 0
|
||||
? await tx
|
||||
.insert(schema.participants)
|
||||
.values(
|
||||
sourceParticipants.map((p: typeof sourceParticipants[number]) => ({
|
||||
sportsSeasonId: newSeason.id,
|
||||
name: p.name,
|
||||
shortName: p.shortName,
|
||||
externalId: p.externalId,
|
||||
}))
|
||||
)
|
||||
.returning()
|
||||
: [];
|
||||
|
||||
// Copy futures odds / Elo stubs matched by externalId then name
|
||||
if (sourceEvByKey.size > 0 && newParticipants.length > 0) {
|
||||
const now = new Date();
|
||||
const evRows = newParticipants
|
||||
.map((newP: typeof newParticipants[number]) => {
|
||||
const key = newP.externalId ?? newP.name;
|
||||
const sourceEv = sourceEvByKey.get(key);
|
||||
if (!sourceEv) return null;
|
||||
return {
|
||||
participantId: newP.id,
|
||||
sportsSeasonId: newSeason.id,
|
||||
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.participantExpectedValues).values(evRows);
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceEvents.length > 0) {
|
||||
await tx.insert(schema.scoringEvents).values(
|
||||
sourceEvents.map((e: typeof sourceEvents[number]) => ({
|
||||
sportsSeasonId: newSeason.id,
|
||||
name: e.name,
|
||||
eventType: e.eventType,
|
||||
isQualifyingEvent: e.isQualifyingEvent,
|
||||
bracketTemplateId: e.bracketTemplateId,
|
||||
scoringStartsAtRound: e.scoringStartsAtRound,
|
||||
isComplete: false,
|
||||
eventDate: e.eventDate ? shiftDateByYears(e.eventDate, yearDelta) : null,
|
||||
eventStartsAt: e.eventStartsAt ? shiftTimestampByYears(e.eventStartsAt, yearDelta) : null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceQPConfig) {
|
||||
await updateQPConfig(
|
||||
newSeason.id,
|
||||
sourceQPConfig.map((qp: typeof sourceQPConfig[number]) => ({
|
||||
placement: qp.placement,
|
||||
points: parseFloat(qp.points),
|
||||
})),
|
||||
tx as ReturnType<typeof database>
|
||||
);
|
||||
}
|
||||
|
||||
return newSeason;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ export default [
|
|||
"sports-seasons/:id/simulate",
|
||||
"routes/admin.sports-seasons.$id.simulate.tsx"
|
||||
),
|
||||
route(
|
||||
"sports-seasons/:id/clone",
|
||||
"routes/admin.sports-seasons.$id.clone.tsx"
|
||||
),
|
||||
route("participants", "routes/admin.participants.tsx"),
|
||||
route("templates", "routes/admin.templates.tsx"),
|
||||
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||
|
|
|
|||
345
app/routes/admin.sports-seasons.$id.clone.tsx
Normal file
345
app/routes/admin.sports-seasons.$id.clone.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { Form, Link, redirect } from "react-router";
|
||||
import { getAuth } from "@clerk/react-router/server";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.clone";
|
||||
|
||||
import { logger } from "~/lib/logger";
|
||||
import { findSportsSeasonById, cloneSportsSeason, shiftDateByYears, type NewSportsSeason } from "~/models/sports-season";
|
||||
import { isUserAdminByClerkId } from "~/models/user";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Clone ${data?.sourceSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sourceSeason = await findSportsSeasonById(params.id);
|
||||
|
||||
if (!sourceSeason) {
|
||||
throw new Response("Sports season not found", { status: 404 });
|
||||
}
|
||||
|
||||
const delta = 1;
|
||||
const newYear = sourceSeason.year + delta;
|
||||
|
||||
const defaults = {
|
||||
name: sourceSeason.name.replace(/\b(20\d{2})\b/g, (_, y) => String(parseInt(y, 10) + delta)),
|
||||
year: newYear,
|
||||
startDate: sourceSeason.startDate ? shiftDateByYears(sourceSeason.startDate, delta) : "",
|
||||
endDate: sourceSeason.endDate ? shiftDateByYears(sourceSeason.endDate, delta) : "",
|
||||
draftOn: shiftDateByYears(sourceSeason.draftOn, delta),
|
||||
draftOff: shiftDateByYears(sourceSeason.draftOff, delta),
|
||||
scoringType: sourceSeason.scoringType,
|
||||
scoringPattern: sourceSeason.scoringPattern ?? "",
|
||||
totalMajors: sourceSeason.totalMajors ?? 4,
|
||||
};
|
||||
|
||||
return { sourceSeason, defaults };
|
||||
}
|
||||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
const { request, params } = args;
|
||||
const { userId } = await getAuth(args);
|
||||
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false;
|
||||
if (!isAdmin) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const sportId = formData.get("sportId");
|
||||
const name = formData.get("name");
|
||||
const year = formData.get("year");
|
||||
const startDate = formData.get("startDate");
|
||||
const endDate = formData.get("endDate");
|
||||
const scoringType = formData.get("scoringType");
|
||||
const scoringPattern = formData.get("scoringPattern");
|
||||
const totalMajors = formData.get("totalMajors");
|
||||
const draftOn = formData.get("draftOn");
|
||||
const draftOff = formData.get("draftOff");
|
||||
|
||||
// Validation
|
||||
if (typeof sportId !== "string" || !sportId) {
|
||||
return { error: "Sport ID is missing" };
|
||||
}
|
||||
|
||||
// Cross-validate sportId against source season to prevent tampering
|
||||
const sourceSeason = await findSportsSeasonById(params.id);
|
||||
if (!sourceSeason) {
|
||||
throw new Response("Sports season not found", { status: 404 });
|
||||
}
|
||||
if (sportId !== sourceSeason.sportId) {
|
||||
return { error: "Sport ID does not match source season" };
|
||||
}
|
||||
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return { error: "Season name is required" };
|
||||
}
|
||||
|
||||
if (typeof year !== "string") {
|
||||
return { error: "Year is required" };
|
||||
}
|
||||
|
||||
const yearNum = parseInt(year, 10);
|
||||
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
|
||||
return { error: "Year must be between 2000 and 2100" };
|
||||
}
|
||||
|
||||
if (scoringType !== "playoffs" && scoringType !== "regular_season" && scoringType !== "majors") {
|
||||
return { error: "Invalid scoring type" };
|
||||
}
|
||||
|
||||
const validScoringPatterns = ["playoff_bracket", "season_standings", "qualifying_points"];
|
||||
if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) {
|
||||
return { error: "Invalid scoring pattern" };
|
||||
}
|
||||
|
||||
if (typeof draftOn !== "string" || !draftOn) {
|
||||
return { error: "Draft open date is required" };
|
||||
}
|
||||
|
||||
if (typeof draftOff !== "string" || !draftOff) {
|
||||
return { error: "Draft close date is required" };
|
||||
}
|
||||
|
||||
if (draftOff < draftOn) {
|
||||
return { error: "Draft close date must be on or after draft open date" };
|
||||
}
|
||||
|
||||
const newSeasonData: Partial<NewSportsSeason> = {
|
||||
sportId,
|
||||
name: name.trim(),
|
||||
year: yearNum,
|
||||
startDate: typeof startDate === "string" && startDate ? startDate : null,
|
||||
endDate: typeof endDate === "string" && endDate ? endDate : null,
|
||||
status: "upcoming",
|
||||
simulationStatus: "idle",
|
||||
majorsCompleted: 0,
|
||||
qualifyingPointsFinalized: false,
|
||||
scoringType,
|
||||
draftOn,
|
||||
draftOff,
|
||||
};
|
||||
|
||||
if (scoringPattern && typeof scoringPattern === "string") {
|
||||
newSeasonData.scoringPattern = scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points";
|
||||
}
|
||||
|
||||
if (totalMajors && typeof totalMajors === "string") {
|
||||
const totalMajorsNum = parseInt(totalMajors, 10);
|
||||
if (!isNaN(totalMajorsNum) && totalMajorsNum > 0) {
|
||||
newSeasonData.totalMajors = totalMajorsNum;
|
||||
}
|
||||
}
|
||||
|
||||
let newSeason;
|
||||
try {
|
||||
newSeason = await cloneSportsSeason(params.id, newSeasonData as NewSportsSeason);
|
||||
} catch (error) {
|
||||
logger.error("Error cloning sports season:", error);
|
||||
return { error: "Failed to clone sports season. Please try again." };
|
||||
}
|
||||
|
||||
return redirect(`/admin/sports-seasons/${newSeason.id}`);
|
||||
}
|
||||
|
||||
export default function CloneSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { sourceSeason, defaults } = loaderData;
|
||||
const [scoringPattern, setScoringPattern] = useState<string>(defaults.scoringPattern);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h1 className="text-3xl font-bold">Clone Sports Season</h1>
|
||||
<Badge variant="secondary">
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
Clone
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Cloning from: <span className="font-medium text-foreground">{sourceSeason.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>New Sports Season Details</CardTitle>
|
||||
<CardDescription>
|
||||
Review and adjust the pre-filled settings. Participants, events, futures odds, and Elo ratings will be copied automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-6">
|
||||
<input type="hidden" name="sportId" value={sourceSeason.sportId} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Sport</Label>
|
||||
<div className="flex h-9 w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||
{sourceSeason.sport.name}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Sport is locked to the source season.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Season Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
defaultValue={defaults.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="year">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
name="year"
|
||||
type="number"
|
||||
min="2000"
|
||||
max="2100"
|
||||
defaultValue={defaults.year}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startDate">Start Date (Optional)</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
type="date"
|
||||
defaultValue={defaults.startDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endDate">End Date (Optional)</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
type="date"
|
||||
defaultValue={defaults.endDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringType">Scoring Type</Label>
|
||||
<Select name="scoringType" defaultValue={defaults.scoringType} required>
|
||||
<SelectTrigger id="scoringType">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoffs">Playoffs</SelectItem>
|
||||
<SelectItem value="regular_season">Regular Season</SelectItem>
|
||||
<SelectItem value="majors">Majors</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
|
||||
<Select name="scoringPattern" value={scoringPattern} onValueChange={setScoringPattern}>
|
||||
<SelectTrigger id="scoringPattern">
|
||||
<SelectValue placeholder="Select scoring pattern (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoff_bracket">Playoff Bracket</SelectItem>
|
||||
<SelectItem value="season_standings">Season Standings</SelectItem>
|
||||
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{scoringPattern === "qualifying_points" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totalMajors">Total Majors</Label>
|
||||
<Input
|
||||
id="totalMajors"
|
||||
name="totalMajors"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
defaultValue={defaults.totalMajors}
|
||||
placeholder="e.g., 4 (for Golf)"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How many major tournaments will be tracked?
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftOn">Draft Open Date</Label>
|
||||
<Input
|
||||
id="draftOn"
|
||||
name="draftOn"
|
||||
type="date"
|
||||
defaultValue={defaults.draftOn}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftOff">Draft Close Date</Label>
|
||||
<Input
|
||||
id="draftOff"
|
||||
name="draftOff"
|
||||
type="date"
|
||||
defaultValue={defaults.draftOff}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This season appears in league creation and pre-draft settings only between these two dates (inclusive).
|
||||
</p>
|
||||
|
||||
{actionData?.error && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{actionData.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" className="flex-1">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Clone Sports Season
|
||||
</Button>
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sourceSeason.id}`}>Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
|
|
@ -308,11 +308,19 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Edit Sports Season</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{sportsSeason.sport.name} - {sportsSeason.name}
|
||||
</p>
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Edit Sports Season</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{sportsSeason.sport.name} - {sportsSeason.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/clone`}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Clone Season
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue