diff --git a/app/models/__tests__/sports-season.test.ts b/app/models/__tests__/sports-season.test.ts index 0e4084a..4bbabd1 100644 --- a/app/models/__tests__/sports-season.test.ts +++ b/app/models/__tests__/sports-season.test.ts @@ -4,13 +4,41 @@ vi.mock("~/database/context", () => ({ database: vi.fn(), })); +// Mock the schema so the schema module doesn't execute drizzle builder calls, +// following the same pattern as scoring-event-dashboard.test.ts. +vi.mock("~/database/schema", () => ({ + sportsSeasons: { + id: "ss.id", + sportId: "ss.sport_id", + name: "ss.name", + year: "ss.year", + draftOn: "ss.draft_on", + draftOff: "ss.draft_off", + }, +})); + +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 }), +})); + import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season"; import { database } from "~/database/context"; +const today = new Date().toISOString().slice(0, 10); +const future = "2099-12-31"; +const pastStart = "2020-01-01"; +const pastEnd = "2020-12-31"; + const mockSeasons = [ - { id: "season-1", name: "2025 NBA Playoffs", isDraftable: true, year: 2025 }, - { id: "season-2", name: "2025 F1 Season", isDraftable: true, year: 2025 }, - { id: "season-3", name: "2024 Old Golf", isDraftable: false, year: 2024 }, + { id: "season-1", name: "2025 NBA Playoffs", draftOn: today, draftOff: future, year: 2025 }, + { id: "season-2", name: "2025 F1 Season", draftOn: today, draftOff: future, year: 2025 }, + { id: "season-3", name: "2024 Old Golf", draftOn: pastStart, draftOff: pastEnd, year: 2024 }, ]; function makeMockDb(seasons: typeof mockSeasons) { @@ -28,7 +56,7 @@ beforeEach(() => { }); describe("findAllSportsSeasons", () => { - it("returns all sport seasons regardless of isDraftable", async () => { + it("returns all sport seasons regardless of draft window", async () => { vi.mocked(database).mockReturnValue(makeMockDb(mockSeasons) as never); const result = await findAllSportsSeasons(); expect(result).toHaveLength(3); @@ -36,23 +64,23 @@ describe("findAllSportsSeasons", () => { }); describe("findDraftableSportsSeasons", () => { - it("passes isDraftable=true filter to the query", async () => { - const draftableOnly = mockSeasons.filter((s) => s.isDraftable); + it("returns only currently-draftable seasons", async () => { + const draftableOnly = mockSeasons.filter((s) => s.draftOn <= today && today <= s.draftOff); const mockDb = makeMockDb(draftableOnly); vi.mocked(database).mockReturnValue(mockDb as never); const result = await findDraftableSportsSeasons(); expect(result).toHaveLength(2); - expect(result.every((s) => s.isDraftable)).toBe(true); + expect(result.every((s) => s.draftOn <= today && today <= s.draftOff)).toBe(true); }); - it("returns empty array when no draftable seasons exist", async () => { + it("returns empty array when no seasons fall within a draft window", async () => { vi.mocked(database).mockReturnValue(makeMockDb([]) as never); const result = await findDraftableSportsSeasons(); expect(result).toHaveLength(0); }); - it("calls findMany with isDraftable where clause", async () => { + it("calls findMany with a where clause", async () => { const mockDb = makeMockDb([]); vi.mocked(database).mockReturnValue(mockDb as never); diff --git a/app/models/season.ts b/app/models/season.ts index 69b4c26..71ea0fc 100644 --- a/app/models/season.ts +++ b/app/models/season.ts @@ -18,7 +18,8 @@ export interface SeasonWithSportsSeasons extends Season { status: string; scoringType: string; scoringPattern: string | null; - isDraftable: boolean; + draftOn: string; // 'YYYY-MM-DD' + draftOff: string; // 'YYYY-MM-DD' sport: { id: string; name: string; diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index f650f4a..c3633e1 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { eq, sql, lte, gte } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; @@ -102,8 +102,13 @@ export async function findAllSportsSeasons(): Promise { export async function findDraftableSportsSeasons(): Promise { const db = database(); + const today = sql`CURRENT_DATE`; return await db.query.sportsSeasons.findMany({ - where: eq(schema.sportsSeasons.isDraftable, true), + where: (ss, { and }) => + and( + lte(ss.draftOn, today), + gte(ss.draftOff, today) + ), orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)], with: { sport: true, diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index dc3797b..4dda4d6 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -47,7 +47,6 @@ import { AlertDialogTrigger, } from "~/components/ui/alert-dialog"; import { Badge } from "~/components/ui/badge"; -import { Checkbox } from "~/components/ui/checkbox"; import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw } from "lucide-react"; import { useState } from "react"; @@ -223,7 +222,8 @@ export async function action(args: Route.ActionArgs) { const scoringType = formData.get("scoringType"); const scoringPattern = formData.get("scoringPattern"); const totalMajors = formData.get("totalMajors"); - const isDraftable = formData.get("isDraftable") === "true"; + const draftOn = formData.get("draftOn"); + const draftOff = formData.get("draftOff"); // Validation if (typeof name !== "string" || !name.trim()) { @@ -252,6 +252,18 @@ export async function action(args: Route.ActionArgs) { 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" }; + } + try { const updateData: Partial = { name: name.trim(), @@ -260,7 +272,8 @@ export async function action(args: Route.ActionArgs) { endDate: typeof endDate === "string" && endDate ? endDate : null, status, scoringType, - isDraftable, + draftOn, + draftOff, }; if (scoringPattern && typeof scoringPattern === "string") { @@ -424,20 +437,32 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo )} -
-
- +
+ + +
+ +
+ + -
-

- When unchecked, this season will not appear in league creation or pre-draft league settings. -

+

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

{actionData?.error && (
diff --git a/app/routes/admin.sports-seasons.new.tsx b/app/routes/admin.sports-seasons.new.tsx index 7b7503e..ae3d7c2 100644 --- a/app/routes/admin.sports-seasons.new.tsx +++ b/app/routes/admin.sports-seasons.new.tsx @@ -43,6 +43,8 @@ export async function action({ request }: Route.ActionArgs) { 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) { @@ -75,6 +77,18 @@ export async function action({ request }: Route.ActionArgs) { 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" }; + } + try { const seasonData: Partial = { sportId, @@ -84,6 +98,8 @@ export async function action({ request }: Route.ActionArgs) { endDate: typeof endDate === "string" && endDate ? endDate : null, status, scoringType, + draftOn, + draftOff, }; if (scoringPattern && typeof scoringPattern === "string") { @@ -261,6 +277,31 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon
)} +
+
+ + +
+ +
+ + +
+
+

+ 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.tsx b/app/routes/admin.sports-seasons.tsx index 12e1662..2f4d925 100644 --- a/app/routes/admin.sports-seasons.tsx +++ b/app/routes/admin.sports-seasons.tsx @@ -40,6 +40,7 @@ export async function loader() { export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps) { const { sportsSeasons } = loaderData; + const today = new Date().toISOString().slice(0, 10); return (
@@ -88,7 +89,7 @@ export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps) Sport Year Status - Draftable + Draft Window Scoring Type Participants Actions @@ -114,9 +115,19 @@ export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps) - - {season.isDraftable ? "Yes" : "No"} - + {(() => { + const active = season.draftOn <= today && today <= season.draftOff; + return ( +
+ + {active ? "Active" : "Inactive"} + +

+ {season.draftOn} → {season.draftOff} +

+
+ ); + })()}
{season.scoringType.replace("_", " ")} diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index 89ade43..25850ba 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -105,10 +105,11 @@ export async function loader(args: Route.LoaderArgs) { const teams = season ? await findTeamsBySeasonId(season.id) : []; // Merge draftable seasons with any already-linked non-draftable seasons so that // previously-added seasons remain visible and checked in the UI even after being disabled. + const today = new Date().toISOString().slice(0, 10); const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id)); const linkedButNotDraftable = (season?.seasonSports ?? []) .map((s) => s.sportsSeason) - .filter((ss) => !ss.isDraftable && !draftableIds.has(ss.id)); + .filter((ss) => !draftableIds.has(ss.id) && (ss.draftOff < today || ss.draftOn > today)); const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable]; const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : []; diff --git a/app/utils/sports-data-sync.server.ts b/app/utils/sports-data-sync.server.ts index d480ec5..1848913 100644 --- a/app/utils/sports-data-sync.server.ts +++ b/app/utils/sports-data-sync.server.ts @@ -336,6 +336,11 @@ export async function importSportsDataFromJSON( | "playoffs" | "regular_season" | "majors", + // Placeholder: season is not draftable until an admin sets real dates. + // A narrow past window is used so the season never accidentally appears + // in league creation without an intentional update. + draftOn: "2000-01-01", + draftOff: "2000-01-02", }) .returning(); sportsSeasonIdMap.set(seasonKey, created_.id); diff --git a/database/schema.ts b/database/schema.ts index c9bec68..6220bd2 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -297,8 +297,9 @@ export const sportsSeasons = pgTable("sports_seasons", { eloMaxRating: integer("elo_max_rating").default(1750), // Simulation status (prevents concurrent simulation runs) simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"), - // Controls whether this sport season appears in league creation/settings - isDraftable: boolean("is_draftable").notNull().default(true), + // Controls the window during which this sport season appears in league creation/settings + draftOn: date("draft_on").notNull(), + draftOff: date("draft_off").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); diff --git a/drizzle/0069_draft_window.sql b/drizzle/0069_draft_window.sql new file mode 100644 index 0000000..fe4bf4c --- /dev/null +++ b/drizzle/0069_draft_window.sql @@ -0,0 +1,19 @@ +-- Step 1: Add new columns as nullable +ALTER TABLE "sports_seasons" ADD COLUMN "draft_on" date;--> statement-breakpoint +ALTER TABLE "sports_seasons" ADD COLUMN "draft_off" date;--> statement-breakpoint + +-- Step 2: Backfill data based on existing is_draftable flag +UPDATE "sports_seasons" +SET "draft_on" = '2026-04-06', "draft_off" = '2099-12-31' +WHERE "is_draftable" = true;--> statement-breakpoint + +UPDATE "sports_seasons" +SET "draft_on" = '2020-01-01', "draft_off" = '2020-12-31' +WHERE "is_draftable" = false;--> statement-breakpoint + +-- Step 3: Apply NOT NULL constraints +ALTER TABLE "sports_seasons" ALTER COLUMN "draft_on" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "sports_seasons" ALTER COLUMN "draft_off" SET NOT NULL;--> statement-breakpoint + +-- Step 4: Drop old column +ALTER TABLE "sports_seasons" DROP COLUMN "is_draftable"; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 36b96fd..45d4800 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -484,6 +484,13 @@ "when": 1775000000000, "tag": "0068_cs2_major_simulator", "breakpoints": true + }, + { + "idx": 69, + "version": "7", + "when": 1744012800000, + "tag": "0069_draft_window", + "breakpoints": true } ] -} \ No newline at end of file +}