Add "Games to Score" widget to admin dashboard

Shows scoring events for today and tomorrow in a tabbed card, with
status badges (Pending/Scored), a scored/total progress counter, and
direct "Score" / "View" links to each event's results page. Excludes
non-scoring schedule_event types so only actionable events surface.

- app/models/scoring-event.ts: add getEventsForDates() model function
  that queries scoringEvents joined with sportsSeasons/sport for a
  given list of date strings
- app/routes/admin._index.tsx: call getEventsForDates([today, tomorrow])
  in the loader and render the GamesToScore component below the stats row
- app/models/__tests__/scoring-event-dashboard.test.ts: 8 unit tests
  covering empty input, shape mapping, multi-date filtering, completed
  events, and the inArray where conditions

https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5
This commit is contained in:
Claude 2026-03-20 01:53:58 +00:00
parent d271cc6792
commit 4044a6372e
No known key found for this signature in database
3 changed files with 476 additions and 3 deletions

View file

@ -0,0 +1,184 @@
/**
* Tests for getEventsForDates the admin dashboard "Games to Score" query.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── DB mock ─────────────────────────────────────────────────────────────────
const mockFindMany = vi.fn();
const mockDb = {
query: {
scoringEvents: { findMany: mockFindMany },
},
};
vi.mock("~/database/context", () => ({
database: () => mockDb,
}));
vi.mock("~/database/schema", () => ({
scoringEvents: {
eventDate: "se.event_date",
eventType: "se.event_type",
eventStartsAt: "se.event_starts_at",
createdAt: "se.created_at",
},
}));
vi.mock("drizzle-orm", () => ({
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
and: (...args: unknown[]) => ({ type: "and", args }),
asc: (col: unknown) => ({ type: "asc", col }),
desc: (col: unknown) => ({ type: "desc", col }),
gte: (col: unknown, val: unknown) => ({ type: "gte", col, val }),
lte: (col: unknown, val: unknown) => ({ type: "lte", col, val }),
or: (...args: unknown[]) => ({ type: "or", args }),
inArray: (col: unknown, arr: unknown) => ({ type: "inArray", col, arr }),
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
}));
// Must be imported after mocks are registered
import { getEventsForDates } from "../scoring-event";
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeScoringEventRow(overrides: Partial<{
id: string;
name: string;
eventDate: string | null;
eventStartsAt: Date | null;
eventType: string;
isComplete: boolean;
completedAt: Date | null;
sportsSeasonId: string;
sportsSeason: { name: string; sport: { name: string; slug: string | null } };
}> = {}) {
return {
id: overrides.id ?? "event-1",
name: overrides.name ?? "Test Event",
eventDate: overrides.eventDate ?? "2026-03-20",
eventStartsAt: overrides.eventStartsAt ?? null,
eventType: overrides.eventType ?? "playoff_game",
isComplete: overrides.isComplete ?? false,
completedAt: overrides.completedAt ?? null,
sportsSeasonId: overrides.sportsSeasonId ?? "season-1",
sportsSeason: overrides.sportsSeason ?? {
name: "2026 NFL Playoffs",
sport: { name: "NFL", slug: "nfl" },
},
};
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("getEventsForDates", () => {
beforeEach(() => {
mockFindMany.mockReset();
});
it("returns an empty array when no dates are provided", async () => {
const result = await getEventsForDates([]);
expect(result).toEqual([]);
expect(mockFindMany).not.toHaveBeenCalled();
});
it("maps database rows to DashboardScoringEvent shape", async () => {
const row = makeScoringEventRow({
id: "e1",
name: "Conference Finals",
eventDate: "2026-03-20",
eventType: "playoff_game",
isComplete: false,
sportsSeasonId: "ss-1",
sportsSeason: {
name: "2026 NBA Playoffs",
sport: { name: "NBA", slug: "nba" },
},
});
mockFindMany.mockResolvedValueOnce([row]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: "e1",
name: "Conference Finals",
eventDate: "2026-03-20",
eventType: "playoff_game",
isComplete: false,
sportsSeasonId: "ss-1",
sportsSeasonName: "2026 NBA Playoffs",
sportName: "NBA",
sportSlug: "nba",
});
});
it("returns events for multiple dates", async () => {
const todayRow = makeScoringEventRow({ id: "e1", eventDate: "2026-03-20" });
const tomorrowRow = makeScoringEventRow({ id: "e2", eventDate: "2026-03-21" });
mockFindMany.mockResolvedValueOnce([todayRow, tomorrowRow]);
const result = await getEventsForDates(["2026-03-20", "2026-03-21"]);
expect(result).toHaveLength(2);
expect(result.map((e) => e.id)).toEqual(["e1", "e2"]);
});
it("includes completed events with isComplete: true", async () => {
const completedRow = makeScoringEventRow({
id: "e-done",
isComplete: true,
completedAt: new Date("2026-03-20T18:00:00Z"),
});
mockFindMany.mockResolvedValueOnce([completedRow]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result[0].isComplete).toBe(true);
expect(result[0].completedAt).toBeInstanceOf(Date);
});
it("includes eventStartsAt when present", async () => {
const starts = new Date("2026-03-20T19:30:00Z");
const row = makeScoringEventRow({ id: "e1", eventStartsAt: starts });
mockFindMany.mockResolvedValueOnce([row]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result[0].eventStartsAt).toEqual(starts);
});
it("passes both dates as an inArray condition to the query", async () => {
mockFindMany.mockResolvedValueOnce([]);
await getEventsForDates(["2026-03-20", "2026-03-21"]);
const callArgs = mockFindMany.mock.calls[0][0];
// The where clause should contain an inArray condition with both dates
const whereStr = JSON.stringify(callArgs.where);
expect(whereStr).toContain("2026-03-20");
expect(whereStr).toContain("2026-03-21");
});
it("excludes schedule_event types via inArray condition", async () => {
mockFindMany.mockResolvedValueOnce([]);
await getEventsForDates(["2026-03-20"]);
const callArgs = mockFindMany.mock.calls[0][0];
const whereStr = JSON.stringify(callArgs.where);
// The allowed event types should be present
expect(whereStr).toContain("playoff_game");
expect(whereStr).toContain("major_tournament");
expect(whereStr).toContain("final_standings");
// schedule_event should not appear
expect(whereStr).not.toContain("schedule_event");
});
it("returns empty array when DB returns no rows", async () => {
mockFindMany.mockResolvedValueOnce([]);
const result = await getEventsForDates(["2026-03-20"]);
expect(result).toEqual([]);
});
});

View file

@ -544,6 +544,71 @@ export async function getUpcomingEventsForDraftedParticipants(
return Array.from(eventMap.values()); return Array.from(eventMap.values());
} }
export interface DashboardScoringEvent {
id: string;
name: string;
eventDate: string | null;
eventStartsAt: Date | null;
eventType: string;
isComplete: boolean;
completedAt: Date | null;
sportsSeasonId: string;
sportsSeasonName: string;
sportName: string;
sportSlug: string | null;
}
/**
* Get all scoring events (excluding schedule_event type) for the given dates,
* joined with their sports season and sport info. Used for the admin dashboard
* "Games to Score" widget.
*/
export async function getEventsForDates(
dates: string[],
providedDb?: ReturnType<typeof database>
): Promise<DashboardScoringEvent[]> {
if (dates.length === 0) return [];
const db = providedDb || database();
const events = await db.query.scoringEvents.findMany({
where: and(
inArray(schema.scoringEvents.eventDate, dates),
inArray(schema.scoringEvents.eventType, [
"playoff_game",
"major_tournament",
"final_standings",
])
),
orderBy: [
asc(schema.scoringEvents.eventDate),
asc(schema.scoringEvents.eventStartsAt),
asc(schema.scoringEvents.createdAt),
],
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
return events.map((e) => ({
id: e.id,
name: e.name,
eventDate: e.eventDate,
eventStartsAt: e.eventStartsAt,
eventType: e.eventType,
isComplete: e.isComplete,
completedAt: e.completedAt,
sportsSeasonId: e.sportsSeasonId,
sportsSeasonName: e.sportsSeason.name,
sportName: e.sportsSeason.sport.name,
sportSlug: e.sportsSeason.sport.slug,
}));
}
/** /**
* Bulk create scoring events for a sports season. * Bulk create scoring events for a sports season.
* Returns created events in order. * Returns created events in order.

View file

@ -1,9 +1,11 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { useState } from "react";
import type { Route } from "./+types/admin._index"; import type { Route } from "./+types/admin._index";
import { findAllSports } from "~/models/sport"; import { findAllSports } from "~/models/sport";
import { findAllSportsSeasons } from "~/models/sports-season"; import { findAllSportsSeasons } from "~/models/sports-season";
import { findAllSeasonTemplates } from "~/models/season-template"; import { findAllSeasonTemplates } from "~/models/season-template";
import { getEventsForDates } from "~/models/scoring-event";
import { import {
Card, Card,
CardContent, CardContent,
@ -12,17 +14,29 @@ import {
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Trophy, Calendar, FolderKanban, ArrowRight, Camera } from "lucide-react"; import { Badge } from "~/components/ui/badge";
import { Trophy, Calendar, FolderKanban, ArrowRight, CheckCircle2, Clock, ExternalLink } from "lucide-react";
export function meta(): Route.MetaDescriptors { export function meta(): Route.MetaDescriptors {
return [{ title: "Admin - Brackt" }]; return [{ title: "Admin - Brackt" }];
} }
function getTodayAndTomorrowDates() {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const toDateStr = (d: Date) => d.toISOString().split("T")[0];
return { today: toDateStr(today), tomorrow: toDateStr(tomorrow) };
}
export async function loader() { export async function loader() {
const [sports, sportsSeasons, templates] = await Promise.all([ const { today, tomorrow } = getTodayAndTomorrowDates();
const [sports, sportsSeasons, templates, upcomingEvents] = await Promise.all([
findAllSports(), findAllSports(),
findAllSportsSeasons(), findAllSportsSeasons(),
findAllSeasonTemplates(), findAllSeasonTemplates(),
getEventsForDates([today, tomorrow]),
]); ]);
return { return {
@ -31,11 +45,216 @@ export async function loader() {
sportsSeasonsCount: sportsSeasons.length, sportsSeasonsCount: sportsSeasons.length,
templatesCount: templates.length, templatesCount: templates.length,
}, },
upcomingEvents,
today,
tomorrow,
}; };
} }
function formatEventTime(eventStartsAt: string | null): string | null {
if (!eventStartsAt) return null;
const d = new Date(eventStartsAt);
return d.toLocaleTimeString([], { 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;
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.eventDate === today);
const tomorrowEvents = events.filter((e) => e.eventDate === 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.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 || todayEvents.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) { export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
const { stats } = loaderData; const { stats, 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,
}));
return ( return (
<div className="p-8"> <div className="p-8">
@ -89,6 +308,11 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
</Card> </Card>
</div> </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"> <div className="grid gap-6 md:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>