Some checks failed
Deploy to Cloudflare Workers / deploy (push) Has been cancelled
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
CalendarDays,
|
|
Clock,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import type { CalendarMatch } from "@/lib/services/calendar";
|
|
import {
|
|
addMonths,
|
|
buildMonthGrid,
|
|
endOfMonth,
|
|
formatMonthYear,
|
|
matchDisplayDate,
|
|
parseDateKey,
|
|
sameDay,
|
|
startOfMonth,
|
|
toDateKey,
|
|
} from "@/lib/calendar/utils";
|
|
import { apiFetch } from "@/lib/api/client";
|
|
import { cn } from "@/lib/utils";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
|
|
|
function matchWhen(m: CalendarMatch) {
|
|
return m.scheduled_at ?? m.proposed_scheduled_at;
|
|
}
|
|
|
|
function statusVariant(status: string) {
|
|
if (status === "completed" || status === "played") return "default" as const;
|
|
if (status === "scheduled" || status === "confirmed") return "secondary" as const;
|
|
return "outline" as const;
|
|
}
|
|
|
|
export function MatchCalendar({
|
|
apiPath,
|
|
title = "Match calendar",
|
|
description = "Plan around fixtures — days with matches glow on the grid.",
|
|
}: {
|
|
apiPath: "/api/manager/calendar" | "/api/master/calendar";
|
|
title?: string;
|
|
description?: string;
|
|
}) {
|
|
const [month, setMonth] = useState(() => startOfMonth(new Date()));
|
|
const [selected, setSelected] = useState(() => toDateKey(new Date()));
|
|
const [matches, setMatches] = useState<CalendarMatch[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const from = startOfMonth(month).toISOString();
|
|
const to = endOfMonth(month).toISOString();
|
|
const data = await apiFetch<CalendarMatch[]>(
|
|
`${apiPath}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`
|
|
);
|
|
setMatches(data);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to load matches");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [apiPath, month]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const byDate = useMemo(() => {
|
|
const map = new Map<string, CalendarMatch[]>();
|
|
for (const m of matches) {
|
|
const when = matchWhen(m);
|
|
if (!when) continue;
|
|
const key = toDateKey(new Date(when));
|
|
const list = map.get(key) ?? [];
|
|
list.push(m);
|
|
map.set(key, list);
|
|
}
|
|
return map;
|
|
}, [matches]);
|
|
|
|
const unscheduled = useMemo(
|
|
() => matches.filter((m) => !matchWhen(m)),
|
|
[matches]
|
|
);
|
|
|
|
const grid = useMemo(() => buildMonthGrid(month), [month]);
|
|
const selectedMatches = byDate.get(selected) ?? [];
|
|
const selectedDate = parseDateKey(selected);
|
|
const today = new Date();
|
|
|
|
const monthMatchCount = useMemo(() => {
|
|
let n = 0;
|
|
for (const [, list] of byDate) n += list.length;
|
|
return n;
|
|
}, [byDate]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<p className="font-display text-[10px] font-bold uppercase tracking-[0.2em] text-neon">
|
|
Schedule
|
|
</p>
|
|
<h1 className="font-display text-3xl font-black uppercase tracking-tight md:text-4xl">
|
|
{title}
|
|
</h1>
|
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">{description}</p>
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
|
|
<div className="retro-card p-4 md:p-6">
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<CalendarDays className="h-5 w-5 text-neon" />
|
|
<h2 className="font-display text-xl font-bold uppercase tracking-wide">
|
|
{formatMonthYear(month)}
|
|
</h2>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="rounded-full border-border"
|
|
onClick={() => setMonth((m) => addMonths(m, -1))}
|
|
aria-label="Previous month"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="rounded-full"
|
|
onClick={() => {
|
|
const now = startOfMonth(new Date());
|
|
setMonth(now);
|
|
setSelected(toDateKey(new Date()));
|
|
}}
|
|
>
|
|
Today
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="rounded-full border-border"
|
|
onClick={() => setMonth((m) => addMonths(m, 1))}
|
|
aria-label="Next month"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin text-neon" />
|
|
Loading fixtures…
|
|
</div>
|
|
)}
|
|
|
|
{error && !loading && (
|
|
<p className="py-8 text-center text-sm text-red-400">{error}</p>
|
|
)}
|
|
|
|
{!loading && !error && (
|
|
<>
|
|
<div className="mb-2 grid grid-cols-7 gap-1">
|
|
{WEEKDAYS.map((d) => (
|
|
<div
|
|
key={d}
|
|
className="py-1 text-center font-display text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
|
|
>
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-1.5">
|
|
{grid.map((day) => {
|
|
const key = toDateKey(day);
|
|
const inMonth = day.getMonth() === month.getMonth();
|
|
const isSelected = key === selected;
|
|
const isToday = sameDay(day, today);
|
|
const dayMatches = byDate.get(key) ?? [];
|
|
const hasMatches = dayMatches.length > 0;
|
|
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => setSelected(key)}
|
|
className={cn(
|
|
"group relative flex min-h-[52px] flex-col items-center justify-start rounded-xl border px-1 py-2 text-sm transition-all md:min-h-[64px]",
|
|
inMonth ? "border-border/80 bg-secondary/30" : "border-transparent bg-transparent opacity-35",
|
|
isSelected && "retro-card-glow border-neon/40 bg-neon/10",
|
|
!isSelected && hasMatches && "hover:border-neon/30 hover:bg-neon/5",
|
|
!isSelected && !hasMatches && inMonth && "hover:bg-secondary/50"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"font-display text-base font-bold tabular-nums",
|
|
isToday && "neon-text",
|
|
isSelected && !isToday && "text-foreground"
|
|
)}
|
|
>
|
|
{day.getDate()}
|
|
</span>
|
|
{hasMatches && (
|
|
<div className="mt-1 flex flex-wrap justify-center gap-0.5">
|
|
{dayMatches.slice(0, 3).map((m) => (
|
|
<span key={m.id} className="neon-dot" />
|
|
))}
|
|
{dayMatches.length > 3 && (
|
|
<span className="text-[9px] text-neon-muted">
|
|
+{dayMatches.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<p className="mt-4 text-xs text-muted-foreground">
|
|
<span className="neon-text font-semibold">{monthMatchCount}</span>{" "}
|
|
scheduled in {formatMonthYear(month)}
|
|
{unscheduled.length > 0 && (
|
|
<>
|
|
{" · "}
|
|
<span className="text-foreground">{unscheduled.length}</span> awaiting
|
|
date
|
|
</>
|
|
)}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="retro-card retro-card-glow p-4 md:p-5">
|
|
<p className="font-display text-[10px] font-bold uppercase tracking-[0.15em] text-neon">
|
|
{selectedDate.toLocaleDateString(undefined, {
|
|
weekday: "long",
|
|
month: "short",
|
|
day: "numeric",
|
|
})}
|
|
</p>
|
|
<h3 className="font-display mt-1 text-2xl font-black uppercase">
|
|
{selectedMatches.length === 0 ? "No matches" : `${selectedMatches.length} match${selectedMatches.length > 1 ? "es" : ""}`}
|
|
</h3>
|
|
|
|
<ul className="mt-4 space-y-3">
|
|
{selectedMatches.length === 0 && (
|
|
<li className="text-sm text-muted-foreground">
|
|
Pick a glowing day or schedule a fixture from your league.
|
|
</li>
|
|
)}
|
|
{selectedMatches.map((m) => (
|
|
<MatchCard key={m.id} match={m} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{unscheduled.length > 0 && (
|
|
<div className="retro-card p-4">
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
<h3 className="font-display text-sm font-bold uppercase tracking-wide">
|
|
Date TBD
|
|
</h3>
|
|
</div>
|
|
<ul className="max-h-64 space-y-2 overflow-y-auto">
|
|
{unscheduled.map((m) => (
|
|
<MatchCard key={m.id} match={m} compact />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MatchCard({ match, compact }: { match: CalendarMatch; compact?: boolean }) {
|
|
const when = matchWhen(match);
|
|
const href = `/leagues/${match.league_id}/competitions/${match.competition_id}/matches/${match.id}`;
|
|
|
|
return (
|
|
<li>
|
|
<Link
|
|
href={href}
|
|
className={cn(
|
|
"block rounded-xl border border-border/80 bg-background/60 p-3 transition-colors hover:border-neon/40 hover:bg-neon/5",
|
|
compact && "p-2.5"
|
|
)}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant={statusVariant(match.status)} className="text-[10px] uppercase">
|
|
{match.status.replace(/_/g, " ")}
|
|
</Badge>
|
|
{!compact && (
|
|
<span className="text-[10px] text-muted-foreground">{match.league_name}</span>
|
|
)}
|
|
</div>
|
|
<p className={cn("mt-1 font-semibold", compact ? "text-sm" : "text-base")}>
|
|
{match.home_name}{" "}
|
|
<span className="text-muted-foreground font-normal">vs</span> {match.away_name}
|
|
</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">{match.competition_name}</p>
|
|
<p className="mt-1 text-xs text-neon-muted">{matchDisplayDate(when)}</p>
|
|
</Link>
|
|
</li>
|
|
);
|
|
}
|