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)); // Row layout (px). A single-lane row is LANE_HEIGHT + ROW_V_PAD tall; extra // concurrent windows add one lane each so overlapping bars never stack on top // of one another. const LANE_HEIGHT = 30; const LANE_GAP = 8; const ROW_V_PAD = 14; const rowHeight = (laneCount: number) => Math.max(laneCount, 1) * LANE_HEIGHT + ROW_V_PAD; /** * Greedy interval-scheduling: assign each window to the first lane whose last * bar ends before this one starts, otherwise open a new lane. Windows arrive * sorted by draftOn (see findDraftScheduleForHorizon). */ function assignLanes( windows: DraftScheduleWindow[], start: Date ): { placed: Array<{ window: DraftScheduleWindow; lane: number }>; laneCount: number } { const laneEnds: number[] = []; const placed = windows.map((window) => { const startDay = differenceInCalendarDays(parseISO(window.draftOn), start); const endDay = differenceInCalendarDays(parseISO(window.draftOff), start); let lane = laneEnds.findIndex((laneEnd) => laneEnd < startDay); if (lane === -1) { lane = laneEnds.length; laneEnds.push(endDay); } else { laneEnds[lane] = endDay; } return { window, lane }; }); return { placed, laneCount: Math.max(laneEnds.length, 1) }; } 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); // Pre-compute lane assignment + height for each sport row. const rows = sports.map((sport) => { const { placed, laneCount } = assignLanes(sport.windows, start); return { sport, placed, height: rowHeight(laneCount) }; }); 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) => (
); }