brackt/app/routes/admin._index.tsx
Chris Parsons eca1508b3f
Improve admin dashboard stats and fix scoring event bugs (#230)
* Improve admin dashboard stats and fix scoring event date bugs, fixes #92

- Add "Seasons by Status" breakdown card (pre-draft / drafting / active / completed)
- Replace full-table fetches with COUNT queries for sports, sports seasons, and templates
- Fix bracket events with null eventDate disappearing from the Games to Score tabs by
  computing a displayDate from matched playoffMatchGames.scheduledAt in getEventsForDates
- Fix Today tab badge showing stale count when all events are scored
- Hide "Getting Started" checklist once the system has sports configured
- Use explicit "en-US" locale in formatEventTime for deterministic output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix scoring-event-dashboard tests after select/sql changes

- Rename mockSelectDistinct → mockSelect to match implementation change
  from .selectDistinct() to .select()
- Add sql to drizzle-orm mock
- Update mock row shapes to include eventDate and gameDate fields
- Add two tests covering displayDate derivation from eventDate and gameDate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 20:46:54 -07:00

437 lines
17 KiB
TypeScript

import { Link } from "react-router";
import { useState } from "react";
import type { Route } from "./+types/admin._index";
import { countSports } from "~/models/sport";
import { countSportsSeasons } from "~/models/sports-season";
import { countSeasonTemplates } from "~/models/season-template";
import { getEventsForDates } from "~/models/scoring-event";
import { countSeasonsByStatus } from "~/models/season";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Trophy, Calendar, FolderKanban, ArrowRight, CheckCircle2, Clock, ExternalLink, Users } from "lucide-react";
export function meta(): Route.MetaDescriptors {
return [{ title: "Admin - Brackt" }];
}
function toDateStr(d: Date) {
return d.toISOString().split("T")[0];
}
function getTodayAndTomorrowDates() {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
return { today: toDateStr(today), tomorrow: toDateStr(tomorrow) };
}
export async function loader() {
const { today, tomorrow } = getTodayAndTomorrowDates();
const [sportsCount, sportsSeasonsCount, templatesCount, upcomingEvents, seasonStatusCounts] =
await Promise.all([
countSports(),
countSportsSeasons(),
countSeasonTemplates(),
getEventsForDates([today, tomorrow]),
countSeasonsByStatus(),
]);
return {
stats: { sportsCount, sportsSeasonsCount, templatesCount },
seasonStatusCounts,
upcomingEvents,
today,
tomorrow,
};
}
function formatEventTime(eventStartsAt: string | null): string | null {
if (!eventStartsAt) return null;
const d = new Date(eventStartsAt);
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
}
function getEventTypeLabel(eventType: string): string {
switch (eventType) {
case "playoff_game": return "Bracket";
case "major_tournament": return "Tournament";
case "final_standings": return "Final Standings";
default: return eventType;
}
}
type DashboardEvent = {
id: string;
name: string;
eventDate: string | null;
displayDate: string | null;
eventStartsAt: string | null;
eventType: string;
isComplete: boolean;
completedAt: string | null;
sportsSeasonId: string;
sportsSeasonName: string;
sportName: string;
sportSlug: string | null;
};
function GamesToScore({
events,
today,
tomorrow,
}: {
events: DashboardEvent[];
today: string;
tomorrow: string;
}) {
const [activeTab, setActiveTab] = useState<"today" | "tomorrow">("today");
const todayEvents = events.filter((e) => e.displayDate === today);
const tomorrowEvents = events.filter((e) => e.displayDate === tomorrow);
const displayEvents = activeTab === "today" ? todayEvents : tomorrowEvents;
const scored = displayEvents.filter((e) => e.isComplete).length;
const total = displayEvents.length;
const pending = total - scored;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Games to Score</CardTitle>
<CardDescription>Scoring events that need your attention</CardDescription>
</div>
{total > 0 && (
<div className="text-right">
<div className="text-2xl font-bold text-foreground">
{scored}/{total}
</div>
<p className="text-xs text-muted-foreground">scored</p>
</div>
)}
</div>
{/* Tab selector */}
<div className="flex gap-2 mt-3">
<button
onClick={() => setActiveTab("today")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTab === "today"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
Today
{todayEvents.filter((e) => !e.isComplete).length > 0 && (
<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs ${
activeTab === "today"
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}>
{todayEvents.filter((e) => !e.isComplete).length}
</span>
)}
</button>
<button
onClick={() => setActiveTab("tomorrow")}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTab === "tomorrow"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
Tomorrow
{tomorrowEvents.length > 0 && (
<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs ${
activeTab === "tomorrow"
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}>
{tomorrowEvents.length}
</span>
)}
</button>
</div>
</CardHeader>
<CardContent>
{displayEvents.length === 0 ? (
<div className="py-6 text-center text-muted-foreground text-sm">
No scoring events scheduled for {activeTab === "today" ? "today" : "tomorrow"}.
</div>
) : (
<>
{pending > 0 && (
<div className="mb-3 flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-sm text-amber-800 dark:text-amber-300">
<Clock className="h-4 w-4 shrink-0" />
<span>{pending} event{pending !== 1 ? "s" : ""} still need{pending === 1 ? "s" : ""} scoring</span>
</div>
)}
{pending === 0 && total > 0 && (
<div className="mb-3 flex items-center gap-2 rounded-md bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-3 py-2 text-sm text-green-800 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span>All events scored for {activeTab === "today" ? "today" : "tomorrow"}!</span>
</div>
)}
<div className="space-y-2">
{displayEvents.map((event) => (
<div
key={event.id}
className={`flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors ${
event.isComplete
? "border-border/50 bg-muted/30 opacity-70"
: "border-border bg-card hover:bg-muted/50"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{event.name}</span>
<Badge variant="outline" className="text-xs shrink-0">
{getEventTypeLabel(event.eventType)}
</Badge>
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<span className="font-medium text-foreground/70">{event.sportName}</span>
<span>·</span>
<span>{event.sportsSeasonName}</span>
{event.eventStartsAt && (
<>
<span>·</span>
<span>{formatEventTime(event.eventStartsAt)}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-3 shrink-0">
{event.isComplete ? (
<Badge
variant="outline"
className="text-xs border-green-300 text-green-700 dark:border-green-700 dark:text-green-400 bg-green-50 dark:bg-green-950/30"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Scored
</Badge>
) : (
<Badge
variant="outline"
className="text-xs border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30"
>
Pending
</Badge>
)}
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
<Link
to={`/admin/sports-seasons/${event.sportsSeasonId}/events/${event.id}`}
>
{event.isComplete ? "View" : "Score"}
<ExternalLink className="h-3 w-3 ml-1" />
</Link>
</Button>
</div>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
);
}
export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
const { stats, seasonStatusCounts, upcomingEvents, today, tomorrow } = loaderData;
// Serialize dates to strings for the client component
const serializedEvents: DashboardEvent[] = upcomingEvents.map((e) => ({
...e,
eventStartsAt: e.eventStartsAt ? new Date(e.eventStartsAt).toISOString() : null,
completedAt: e.completedAt ? new Date(e.completedAt).toISOString() : null,
}));
const systemIsPopulated = stats.sportsCount > 0;
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<p className="text-muted-foreground mt-2">
Manage sports, seasons, participants, and templates
</p>
</div>
<div className="grid gap-6 md:grid-cols-4 mb-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Seasons by Status</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex flex-col gap-1.5 mt-1">
{(
[
{ key: "pre_draft", label: "Pre-Draft" },
{ key: "draft", label: "Drafting" },
{ key: "active", label: "Active" },
{ key: "completed", label: "Completed" },
] as const
).map(({ key, label }) => (
<div key={key} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">{seasonStatusCounts[key]}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sports</CardTitle>
<Trophy className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.sportsCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Active sports in the system
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Sports Seasons
</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.sportsSeasonsCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Across all sports
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Templates</CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.templatesCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Season templates available
</p>
</CardContent>
</Card>
</div>
{/* Games to Score widget — full width */}
<div className="mb-6">
<GamesToScore events={serializedEvents} today={today} tomorrow={tomorrow} />
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common administrative tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" className="w-full justify-between" asChild>
<Link to="/admin/sports/new">
Create New Sport
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button variant="outline" className="w-full justify-between" asChild>
<Link to="/admin/sports-seasons/new">
Create Sports Season
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button variant="outline" className="w-full justify-between" asChild>
<Link to="/admin/templates/new">
Create Season Template
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button variant="outline" className="w-full justify-between" asChild>
<Link to="/admin/standings-snapshots">
Manage Standings Snapshots
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
{!systemIsPopulated && (
<Card>
<CardHeader>
<CardTitle>Getting Started</CardTitle>
<CardDescription>Set up your sports system</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start space-x-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
1
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Create Sports</p>
<p className="text-xs text-muted-foreground">
Add sports like NFL, NBA, Golf, etc.
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
2
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Create Sports Seasons</p>
<p className="text-xs text-muted-foreground">
Add specific seasons for each sport
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
3
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Add Participants</p>
<p className="text-xs text-muted-foreground">
Add teams or players to each sports season
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
4
</div>
<div className="space-y-1">
<p className="text-sm font-medium">Create Templates</p>
<p className="text-xs text-muted-foreground">
Bundle sports seasons into templates for leagues
</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}