- Use make_interval(months => $n) instead of parameterized interval
multiplication so Postgres can infer the bound param's type (the prior
form risked a "could not determine data type of parameter" runtime error)
- Fix subject/verb grammar in the coverage-gap warning for a single sport
("1 sport has" vs "N sports have")
- Stack overlapping draft windows for the same sport into separate lanes
via greedy interval scheduling so bars no longer render on top of one
another; row height grows with the number of concurrent windows
- Add a test covering overlapping windows rendering as separate bars
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fd51U9DPMNz4KzBeucR6CK
236 lines
8.3 KiB
TypeScript
236 lines
8.3 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));
|
|
|
|
// 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 (
|
|
<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’s draft-on → 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" />
|
|
{rows.map(({ sport, height }) => (
|
|
<div
|
|
key={sport.id}
|
|
style={{ height }}
|
|
className="flex 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 */}
|
|
{rows.map(({ sport, placed, height }) => (
|
|
<div
|
|
key={sport.id}
|
|
style={{ height }}
|
|
className="relative border-b border-border/50"
|
|
>
|
|
{placed.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>
|
|
) : (
|
|
placed.map(({ window: w, lane }) => {
|
|
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 flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
|
|
style={{
|
|
left: `${left}%`,
|
|
width: `${width}%`,
|
|
top: ROW_V_PAD / 2 + lane * LANE_HEIGHT,
|
|
height: LANE_HEIGHT - LANE_GAP,
|
|
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>
|
|
);
|
|
}
|