Replace isDraftable boolean with draftOn/draftOff date range (#263)
Fixes #262 * Replace isDraftable boolean with draftOn/draftOff date fields on sport seasons Admins can now schedule when a sport season becomes available for drafting by setting explicit open and close dates instead of a manual toggle. https://claude.ai/code/session_01LHYgpyimF8v8odUB6kj8Qc * Address code review feedback on draft window implementation - sports-data-sync: use unambiguous past placeholder dates for synced seasons, with a comment explaining the intent - sports-season model: remove redundant top-level lte/gte imports (callback form already supplies them) - _journal.json: add missing trailing newline - sports-season test: mock ~/database/schema instead of stubbing all drizzle builder functions, matching the established pattern in the codebase - admin list: compute today once in component body instead of per-row https://claude.ai/code/session_01LHYgpyimF8v8odUB6kj8Qc * Fix: restore lte/gte imports removed in review cleanup The relational query where-callback in this version of Drizzle does not expose lte/gte as helper args, so they must be imported from drizzle-orm. Removing them broke CI TypeScript. https://claude.ai/code/session_01LHYgpyimF8v8odUB6kj8Qc --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
fbeee4ed15
commit
2b680d7272
11 changed files with 178 additions and 34 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<SportsSeason[]> {
|
|||
|
||||
export async function findDraftableSportsSeasons(): Promise<SportsSeason[]> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<NewSportsSeason> = {
|
||||
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
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isDraftable"
|
||||
name="isDraftable"
|
||||
defaultChecked={sportsSeason.isDraftable}
|
||||
value="true"
|
||||
<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={sportsSeason.draftOn}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftOff">Draft Close Date</Label>
|
||||
<Input
|
||||
id="draftOff"
|
||||
name="draftOff"
|
||||
type="date"
|
||||
defaultValue={sportsSeason.draftOff}
|
||||
required
|
||||
/>
|
||||
<Label htmlFor="isDraftable">Available for drafting</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When unchecked, this season will not appear in league creation or pre-draft league settings.
|
||||
</p>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -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<NewSportsSeason> = {
|
||||
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
|
|||
</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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftOff">Draft Close Date</Label>
|
||||
<Input
|
||||
id="draftOff"
|
||||
name="draftOff"
|
||||
type="date"
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="p-8">
|
||||
|
|
@ -88,7 +89,7 @@ export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps)
|
|||
<TableHead>Sport</TableHead>
|
||||
<TableHead>Year</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Draftable</TableHead>
|
||||
<TableHead>Draft Window</TableHead>
|
||||
<TableHead>Scoring Type</TableHead>
|
||||
<TableHead>Participants</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
|
|
@ -114,9 +115,19 @@ export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps)
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={season.isDraftable ? "default" : "secondary"}>
|
||||
{season.isDraftable ? "Yes" : "No"}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const active = season.draftOn <= today && today <= season.draftOff;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<Badge variant={active ? "default" : "secondary"}>
|
||||
{active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{season.draftOn} → {season.draftOff}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground capitalize">
|
||||
{season.scoringType.replace("_", " ")}
|
||||
|
|
|
|||
|
|
@ -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) : [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
19
drizzle/0069_draft_window.sql
Normal file
19
drizzle/0069_draft_window.sql
Normal file
|
|
@ -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";
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue