feat: clone sports season (#282) (#288)

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:
Chris Parsons 2026-04-12 01:03:37 -04:00 committed by GitHub
parent dc879e3033
commit fa16581f9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 925 additions and 26 deletions

View 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"
);
});
});

View file

@ -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;
});
}

View file

@ -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"),

View 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>
);
}

View file

@ -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">