brackt/app/components/admin/DraftScheduleGantt.tsx
Claude 7f021aa534
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd51U9DPMNz4KzBeucR6CK
2026-07-02 00:44:23 +00:00

192 lines
6.6 KiB
TypeScript

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<DraftScheduleWindow["status"], string> = {
active: "var(--chart-1)",
upcoming: "var(--chart-2)",
completed: "var(--muted-foreground)",
};
const STATUS_LABELS: Record<DraftScheduleWindow["status"], string> = {
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 (
<Card>
<CardHeader>
<CardTitle>Draft Schedule</CardTitle>
<CardDescription>Draft windows across the next {months} months</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12 text-muted-foreground">
<p>No sports found.</p>
<p className="text-sm mt-2">Create a sport to start scheduling draft windows.</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>Draft Schedule</CardTitle>
<CardDescription>
Draft windows across the next {months} months (each bar spans a
sport-season&rsquo;s draft-on &rarr; draft-off window)
</CardDescription>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{(Object.keys(STATUS_COLORS) as DraftScheduleWindow["status"][]).map((status) => (
<div key={status} className="flex items-center gap-1.5">
<span
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: STATUS_COLORS[status] }}
aria-hidden="true"
/>
<span>{STATUS_LABELS[status]}</span>
</div>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="flex" style={{ minWidth: 160 + months * 80 }}>
{/* Sport labels column */}
<div className="w-40 shrink-0">
<div className="h-8" aria-hidden="true" />
{sports.map((sport) => (
<div
key={sport.id}
className="flex h-11 items-center border-b border-border/50 pr-2"
>
<span className="truncate text-sm font-medium" title={sport.name}>
{sport.name}
</span>
</div>
))}
</div>
{/* Timeline column */}
<div className="relative flex-1">
{/* Month gridlines (full height) */}
{monthLines.map((m) => (
<div
key={`line-${m.date.toISOString()}`}
className="pointer-events-none absolute bottom-0 top-0 w-px bg-border/60"
style={{ left: `${m.left}%` }}
aria-hidden="true"
/>
))}
{/* Today marker (full height) */}
<div
className="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-primary/80"
style={{ left: 0 }}
aria-hidden="true"
/>
{/* Month labels header */}
<div className="relative h-8">
{monthLines.map((m) => (
<span
key={`label-${m.date.toISOString()}`}
className="absolute top-1 text-xs text-muted-foreground"
style={{ left: `calc(${m.left}% + 4px)` }}
>
{format(m.date, "MMM yyyy")}
</span>
))}
</div>
{/* Rows */}
{sports.map((sport) => (
<div
key={sport.id}
className="relative h-11 border-b border-border/50"
>
{sport.windows.length === 0 ? (
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs italic text-muted-foreground/70">
No draft window
</span>
) : (
sport.windows.map((w) => {
const left = clampPct(pct(parseISO(w.draftOn)));
const right = clampPct(pct(parseISO(w.draftOff)));
const width = Math.max(right - left, 0.75);
return (
<Link
key={w.id}
to={`/admin/sports-seasons/${w.id}`}
className="absolute top-1/2 flex h-6 -translate-y-1/2 items-center overflow-hidden rounded px-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: STATUS_COLORS[w.status],
}}
title={`${w.name} (${w.year}) · ${w.draftOn}${w.draftOff}`}
>
<span className="truncate">{w.name}</span>
</Link>
);
})
)}
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
}