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 }),
|
||||
}));
|
||||
|
||||
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(),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
id: string,
|
||||
data: Partial<NewSportsSeason>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
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,
|
||||
Menu,
|
||||
Shield,
|
||||
CalendarRange,
|
||||
} from "lucide-react";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
|
|
@ -72,6 +73,12 @@ function AdminNavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
|||
Sports Seasons
|
||||
</Link>
|
||||
</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}>
|
||||
<Link to="/admin/simulators">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue