Merge pull request 'add gantt chart' (#124) from claude/draft-schedule-gantt-9in9u9 into main
Reviewed-on: #124
This commit is contained in:
commit
7cbcc6fb14
7 changed files with 575 additions and 1 deletions
236
app/components/admin/DraftScheduleGantt.tsx
Normal file
236
app/components/admin/DraftScheduleGantt.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
app/components/admin/__tests__/DraftScheduleGantt.test.tsx
Normal file
97
app/components/admin/__tests__/DraftScheduleGantt.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DraftScheduleGantt sports={sports} today="2026-07-02" months={months} />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("renders overlapping windows as separate, clickable bars", () => {
|
||||||
|
const overlappingWindow = {
|
||||||
|
id: "ss-2",
|
||||||
|
name: "2027 NBA Playoffs",
|
||||||
|
year: 2027,
|
||||||
|
status: "active" as const,
|
||||||
|
draftOn: "2026-08-15",
|
||||||
|
draftOff: "2026-10-01",
|
||||||
|
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
renderGantt([
|
||||||
|
{
|
||||||
|
id: "nba",
|
||||||
|
name: "NBA",
|
||||||
|
slug: "nba",
|
||||||
|
iconUrl: null,
|
||||||
|
windows: [nbaWindow, overlappingWindow],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /2026 NBA Playoffs/ })
|
||||||
|
).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /2027 NBA Playoffs/ })
|
||||||
|
).toHaveAttribute("href", "/admin/sports-seasons/ss-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -27,7 +27,11 @@ vi.mock("drizzle-orm", () => ({
|
||||||
asc: (col: unknown) => ({ type: "asc", col }),
|
asc: (col: unknown) => ({ type: "asc", col }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
|
import {
|
||||||
|
findAllSportsSeasons,
|
||||||
|
findDraftableSportsSeasons,
|
||||||
|
findDraftScheduleForHorizon,
|
||||||
|
} from "../sports-season";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
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(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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<DraftScheduleWindow[]> {
|
||||||
|
const db = database();
|
||||||
|
const horizonEnd = sql`CURRENT_DATE + make_interval(months => ${monthsAhead})`;
|
||||||
|
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(
|
export async function updateSportsSeason(
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<NewSportsSeason>
|
data: Partial<NewSportsSeason>
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export default [
|
||||||
route("simulators", "routes/admin.simulators.tsx"),
|
route("simulators", "routes/admin.simulators.tsx"),
|
||||||
route("sports/new", "routes/admin.sports.new.tsx"),
|
route("sports/new", "routes/admin.sports.new.tsx"),
|
||||||
route("sports/:id", "routes/admin.sports.$id.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", "routes/admin.sports-seasons.tsx"),
|
||||||
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
|
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
|
||||||
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),
|
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),
|
||||||
|
|
|
||||||
123
app/routes/admin.draft-schedule.tsx
Normal file
123
app/routes/admin.draft-schedule.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
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<string, typeof windows>();
|
||||||
|
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 (
|
||||||
|
<div className="p-4 md:p-8">
|
||||||
|
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Draft Schedule</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
See when each sport is scheduled to be drafted and spot gaps in coverage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 rounded-md border p-1">
|
||||||
|
{ALLOWED_MONTHS.map((m) => (
|
||||||
|
<Button
|
||||||
|
key={m}
|
||||||
|
variant={m === months ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={`?months=${m}`}>{m} months</Link>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sportsWithoutCoverage.length > 0 && (
|
||||||
|
<Card className="mb-6 border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
Sports without a draft window
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-amber-700 dark:text-amber-400/80">
|
||||||
|
{sportsWithoutCoverage.length} sport
|
||||||
|
{sportsWithoutCoverage.length !== 1 ? "s" : ""}{" "}
|
||||||
|
{sportsWithoutCoverage.length !== 1 ? "have" : "has"} no draft window
|
||||||
|
open or upcoming in the next {months} months.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sportsWithoutCoverage.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-background/60 px-3 py-2 dark:border-amber-900"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{s.name}</span>
|
||||||
|
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
|
||||||
|
<Link to="/admin/sports-seasons/new">
|
||||||
|
Schedule a season
|
||||||
|
<ArrowRight className="ml-1 h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DraftScheduleGantt sports={sports} today={today} months={months} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Menu,
|
Menu,
|
||||||
Shield,
|
Shield,
|
||||||
|
CalendarRange,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export function meta(): Route.MetaDescriptors {
|
export function meta(): Route.MetaDescriptors {
|
||||||
|
|
@ -72,6 +73,12 @@ function AdminNavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
Sports Seasons
|
Sports Seasons
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
|
||||||
|
<Link to="/admin/draft-schedule">
|
||||||
|
<CalendarRange className="mr-2 h-4 w-4" />
|
||||||
|
Draft Schedule
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
|
<Button variant="ghost" className="w-full justify-start" asChild onClick={onNavigate}>
|
||||||
<Link to="/admin/simulators">
|
<Link to="/admin/simulators">
|
||||||
<Activity className="mr-2 h-4 w-4" />
|
<Activity className="mr-2 h-4 w-4" />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue