diff --git a/app/models/__tests__/sports-season.clone.test.ts b/app/models/__tests__/sports-season.clone.test.ts new file mode 100644 index 0000000..b209c18 --- /dev/null +++ b/app/models/__tests__/sports-season.clone.test.ts @@ -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>).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 = {}; + + // 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) => 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>; + 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>; + 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>; + 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" + ); + }); +}); diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index 6a5a9c8..da32417 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -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 { const db = database(); @@ -103,7 +104,7 @@ export async function findAllSportsSeasons(): Promise { const statusOrder: Record = { 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 { await db.delete(schema.sportsSeasons).where(eq(schema.sportsSeasons.id, id)); } -export async function copyParticipantsFromPreviousSeason( - currentSeasonId: string, - previousSeasonId: string -): Promise { +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 { 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(); + 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 => 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 + ); + } + + return newSeason; + }); } diff --git a/app/routes.ts b/app/routes.ts index 5d586d0..723812a 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/routes/admin.sports-seasons.$id.clone.tsx b/app/routes/admin.sports-seasons.$id.clone.tsx new file mode 100644 index 0000000..123cb99 --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.clone.tsx @@ -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 = { + 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(defaults.scoringPattern); + + return ( +
+
+
+
+

Clone Sports Season

+ + + Clone + +
+

+ Cloning from: {sourceSeason.name} +

+
+ + + + New Sports Season Details + + Review and adjust the pre-filled settings. Participants, events, futures odds, and Elo ratings will be copied automatically. + + + +
+ + +
+ +
+ {sourceSeason.sport.name} +
+

Sport is locked to the source season.

+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +

+ Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors. +

+
+ +
+ + +
+ + {scoringPattern === "qualifying_points" && ( +
+ + +

+ How many major tournaments will be tracked? +

+
+ )} + +
+
+ + +
+ +
+ + +
+
+

+ This season appears in league creation and pre-draft settings only between these two dates (inclusive). +

+ + {actionData?.error && ( +
+ {actionData.error} +
+ )} + +
+ + +
+
+
+
+
+
+ ); +} diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index 4dda4d6..62ce0b0 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -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 (
-
-

Edit Sports Season

-

- {sportsSeason.sport.name} - {sportsSeason.name} -

+
+
+

Edit Sports Season

+

+ {sportsSeason.sport.name} - {sportsSeason.name} +

+
+