From 7f021aa534e536b32305f79a81f6ef7be95906bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 00:44:23 +0000 Subject: [PATCH] Add admin draft schedule Gantt chart Adds an admin page at /admin/draft-schedule that visualizes sport-season draft windows (draftOn -> draftOff) as a Gantt-style timeline: Y axis is the sport, X axis is a 6- or 12-month horizon. A warning panel highlights sports with no open or upcoming draft window so gaps in coverage are obvious at a glance. - New findDraftScheduleForHorizon model query (overlap test against the horizon, admin sport-seasons only) - Presentational DraftScheduleGantt component with month gridlines, a "today" marker, and status-colored bars linking to each sport-season - Route registered in routes.ts and linked from the admin nav - Unit tests for the model query and component Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Fd51U9DPMNz4KzBeucR6CK --- app/components/admin/DraftScheduleGantt.tsx | 192 ++++++++++++++++++ .../__tests__/DraftScheduleGantt.test.tsx | 68 +++++++ app/models/__tests__/sports-season.test.ts | 61 +++++- app/models/sports-season.ts | 51 +++++ app/routes.ts | 1 + app/routes/admin.draft-schedule.tsx | 122 +++++++++++ app/routes/admin.tsx | 7 + 7 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 app/components/admin/DraftScheduleGantt.tsx create mode 100644 app/components/admin/__tests__/DraftScheduleGantt.test.tsx create mode 100644 app/routes/admin.draft-schedule.tsx diff --git a/app/components/admin/DraftScheduleGantt.tsx b/app/components/admin/DraftScheduleGantt.tsx new file mode 100644 index 0000000..cf062d3 --- /dev/null +++ b/app/components/admin/DraftScheduleGantt.tsx @@ -0,0 +1,192 @@ +import { Link } from "react-router"; +import { + addMonths, + differenceInCalendarDays, + eachMonthOfInterval, + format, + parseISO, +} from "date-fns"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import type { DraftScheduleWindow } from "~/models/sports-season"; + +export interface GanttSport { + id: string; + name: string; + slug: string; + iconUrl: string | null; + windows: DraftScheduleWindow[]; +} + +interface DraftScheduleGanttProps { + sports: GanttSport[]; + /** Horizon start (today) as a YYYY-MM-DD string. */ + today: string; + /** Number of months the timeline spans. */ + months: number; +} + +// Bar color by sport-season status, using the theme chart tokens from app.css. +const STATUS_COLORS: Record = { + active: "var(--chart-1)", + upcoming: "var(--chart-2)", + completed: "var(--muted-foreground)", +}; + +const STATUS_LABELS: Record = { + active: "Active", + upcoming: "Upcoming", + completed: "Completed", +}; + +const clampPct = (n: number) => Math.max(0, Math.min(100, n)); + +export function DraftScheduleGantt({ sports, today, months }: DraftScheduleGanttProps) { + const start = parseISO(today); + const end = addMonths(start, months); + const totalDays = Math.max(differenceInCalendarDays(end, start), 1); + + const pct = (date: Date) => (differenceInCalendarDays(date, start) / totalDays) * 100; + + // Month-boundary gridlines that fall inside the horizon. + const monthLines = eachMonthOfInterval({ start, end }) + .map((date) => ({ date, left: pct(date) })) + .filter((m) => m.left >= 0 && m.left <= 100); + + if (sports.length === 0) { + return ( + + + Draft Schedule + Draft windows across the next {months} months + + +
+

No sports found.

+

Create a sport to start scheduling draft windows.

+
+
+
+ ); + } + + return ( + + +
+
+ Draft Schedule + + Draft windows across the next {months} months (each bar spans a + sport-season’s draft-on → draft-off window) + +
+
+ {(Object.keys(STATUS_COLORS) as DraftScheduleWindow["status"][]).map((status) => ( +
+
+ ))} +
+
+
+ +
+
+ {/* Sport labels column */} +
+ + + {/* Timeline column */} +
+ {/* Month gridlines (full height) */} + {monthLines.map((m) => ( + +
+ + + ); +} diff --git a/app/components/admin/__tests__/DraftScheduleGantt.test.tsx b/app/components/admin/__tests__/DraftScheduleGantt.test.tsx new file mode 100644 index 0000000..46f385e --- /dev/null +++ b/app/components/admin/__tests__/DraftScheduleGantt.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { DraftScheduleGantt, type GanttSport } from "../DraftScheduleGantt"; + +const nbaWindow = { + id: "ss-1", + name: "2026 NBA Playoffs", + year: 2026, + status: "upcoming" as const, + draftOn: "2026-08-01", + draftOff: "2026-09-15", + sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null }, +}; + +function renderGantt(sports: GanttSport[], months = 6) { + return render( + + + + ); +} + +describe("DraftScheduleGantt", () => { + it("renders empty state when there are no sports", () => { + renderGantt([]); + expect(screen.getByText("No sports found.")).toBeInTheDocument(); + }); + + it("renders a bar linking to the sport-season for each draft window", () => { + renderGantt([ + { id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }, + ]); + + const bar = screen.getByRole("link", { name: /2026 NBA Playoffs/ }); + expect(bar).toHaveAttribute("href", "/admin/sports-seasons/ss-1"); + }); + + it("shows a 'No draft window' hint for a sport with no windows", () => { + renderGantt([ + { id: "golf", name: "Golf", slug: "golf", iconUrl: null, windows: [] }, + ]); + + expect(screen.getByText("Golf")).toBeInTheDocument(); + expect(screen.getByText("No draft window")).toBeInTheDocument(); + }); + + it("renders the status legend", () => { + renderGantt([ + { id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }, + ]); + + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("Upcoming")).toBeInTheDocument(); + expect(screen.getByText("Completed")).toBeInTheDocument(); + }); + + it("reflects the horizon length in the description", () => { + renderGantt( + [{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }], + 12 + ); + + expect( + screen.getByText(/Draft windows across the next 12 months/) + ).toBeInTheDocument(); + }); +}); diff --git a/app/models/__tests__/sports-season.test.ts b/app/models/__tests__/sports-season.test.ts index 5b04078..191608d 100644 --- a/app/models/__tests__/sports-season.test.ts +++ b/app/models/__tests__/sports-season.test.ts @@ -27,7 +27,11 @@ vi.mock("drizzle-orm", () => ({ asc: (col: unknown) => ({ type: "asc", col }), })); -import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season"; +import { + findAllSportsSeasons, + findDraftableSportsSeasons, + findDraftScheduleForHorizon, +} from "../sports-season"; import { database } from "~/database/context"; const today = new Date().toISOString().slice(0, 10); @@ -125,3 +129,58 @@ describe("findDraftableSportsSeasons", () => { ); }); }); + +describe("findDraftScheduleForHorizon", () => { + const horizonWindows = [ + { + id: "w1", + name: "2026 NBA Playoffs", + year: 2026, + status: "upcoming", + draftOn: today, + draftOff: future, + sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null }, + }, + { + id: "w2", + name: "2026 F1 Season", + year: 2026, + status: "active", + draftOn: today, + draftOff: future, + sport: { id: "f1", name: "F1", slug: "f1", iconUrl: null }, + }, + ]; + + it("maps rows to windows carrying their sport info", async () => { + vi.mocked(database).mockReturnValue(makeMockDb(horizonWindows) as never); + const result = await findDraftScheduleForHorizon(6); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + id: "w1", + draftOn: today, + draftOff: future, + sport: { id: "nba", name: "NBA" }, + }); + }); + + it("returns an empty array when no windows overlap the horizon", async () => { + vi.mocked(database).mockReturnValue(makeMockDb([]) as never); + const result = await findDraftScheduleForHorizon(12); + expect(result).toHaveLength(0); + }); + + it("queries with a where clause, ordering, and the sport relation", async () => { + const mockDb = makeMockDb([]); + vi.mocked(database).mockReturnValue(mockDb as never); + + await findDraftScheduleForHorizon(6); + expect(mockDb.query.sportsSeasons.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.anything(), + orderBy: expect.anything(), + with: expect.anything(), + }) + ); + }); +}); diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index 72acfe7..7d81c01 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -169,6 +169,57 @@ export async function findDraftableSportsSeasonBySportId(sportId: string) { }); } +export type DraftScheduleWindow = { + id: string; + name: string; + year: number; + status: SportsSeasonStatus; + draftOn: string; + draftOff: string; + sport: { + id: string; + name: string; + slug: string; + iconUrl: string | null; + }; +}; + +/** + * Returns admin sport-seasons (fantasySeasonId IS NULL) whose draft window + * [draftOn, draftOff] overlaps the horizon [today, today + monthsAhead]. Uses an + * overlap test (not containment) so windows already open, or spanning the horizon + * edges, are still included. Powers the admin draft-schedule Gantt chart. + */ +export async function findDraftScheduleForHorizon( + monthsAhead: number +): Promise { + const db = database(); + const horizonEnd = sql`CURRENT_DATE + (${monthsAhead} * interval '1 month')`; + const seasons = await db.query.sportsSeasons.findMany({ + where: (ss, { and }) => + and( + isNull(ss.fantasySeasonId), + gte(ss.draftOff, sql`CURRENT_DATE`), + lte(ss.draftOn, horizonEnd) + ), + orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.draftOn)], + with: { + sport: { + columns: { id: true, name: true, slug: true, iconUrl: true }, + }, + }, + }); + return seasons.map((s) => ({ + id: s.id, + name: s.name, + year: s.year, + status: s.status, + draftOn: s.draftOn, + draftOff: s.draftOff, + sport: s.sport, + })) as DraftScheduleWindow[]; +} + export async function updateSportsSeason( id: string, data: Partial diff --git a/app/routes.ts b/app/routes.ts index c4300dc..1f81613 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -96,6 +96,7 @@ export default [ route("simulators", "routes/admin.simulators.tsx"), route("sports/new", "routes/admin.sports.new.tsx"), route("sports/:id", "routes/admin.sports.$id.tsx"), + route("draft-schedule", "routes/admin.draft-schedule.tsx"), route("sports-seasons", "routes/admin.sports-seasons.tsx"), route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"), route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"), diff --git a/app/routes/admin.draft-schedule.tsx b/app/routes/admin.draft-schedule.tsx new file mode 100644 index 0000000..17043c8 --- /dev/null +++ b/app/routes/admin.draft-schedule.tsx @@ -0,0 +1,122 @@ +import { Link } from "react-router"; +import type { Route } from "./+types/admin.draft-schedule"; + +import { findAllSports } from "~/models/sport"; +import { findDraftScheduleForHorizon } from "~/models/sports-season"; +import { + DraftScheduleGantt, + type GanttSport, +} from "~/components/admin/DraftScheduleGantt"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { AlertTriangle, ArrowRight } from "lucide-react"; + +const ALLOWED_MONTHS = [6, 12] as const; + +export function meta(): Route.MetaDescriptors { + return [{ title: "Draft Schedule - Brackt" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url); + const monthsParam = Number(url.searchParams.get("months")); + const months = ALLOWED_MONTHS.includes(monthsParam as (typeof ALLOWED_MONTHS)[number]) + ? monthsParam + : 6; + + const [allSports, windows] = await Promise.all([ + findAllSports(), + findDraftScheduleForHorizon(months), + ]); + + const windowsBySport = new Map(); + for (const w of windows) { + const list = windowsBySport.get(w.sport.id) ?? []; + list.push(w); + windowsBySport.set(w.sport.id, list); + } + + const sports: GanttSport[] = allSports.map((s) => ({ + id: s.id, + name: s.name, + slug: s.slug, + iconUrl: s.iconUrl, + windows: windowsBySport.get(s.id) ?? [], + })); + + const sportsWithoutCoverage = sports.filter((s) => s.windows.length === 0); + const today = new Date().toISOString().split("T")[0]; + + return { sports, sportsWithoutCoverage, today, months }; +} + +export default function AdminDraftSchedule({ loaderData }: Route.ComponentProps) { + const { sports, sportsWithoutCoverage, today, months } = loaderData; + + return ( +
+
+
+

Draft Schedule

+

+ See when each sport is scheduled to be drafted and spot gaps in coverage. +

+
+
+ {ALLOWED_MONTHS.map((m) => ( + + ))} +
+
+ + {sportsWithoutCoverage.length > 0 && ( + + + + + Sports without a draft window + + + {sportsWithoutCoverage.length} sport + {sportsWithoutCoverage.length !== 1 ? "s" : ""} have no draft window + open or upcoming in the next {months} months. + + + +
    + {sportsWithoutCoverage.map((s) => ( +
  • + {s.name} + +
  • + ))} +
+
+
+ )} + + +
+ ); +} diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index 16df021..9107c9d 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -23,6 +23,7 @@ import { Users, Menu, Shield, + CalendarRange, } from "lucide-react"; export function meta(): Route.MetaDescriptors { @@ -72,6 +73,12 @@ function AdminNavLinks({ onNavigate }: { onNavigate?: () => void }) { Sports Seasons +