diff --git a/app/models/__tests__/scoring-event-dashboard.test.ts b/app/models/__tests__/scoring-event-dashboard.test.ts new file mode 100644 index 0000000..1cd6818 --- /dev/null +++ b/app/models/__tests__/scoring-event-dashboard.test.ts @@ -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([]); + }); +}); diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 78b8a04..f024489 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -544,6 +544,71 @@ export async function getUpcomingEventsForDraftedParticipants( 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 +): Promise { + 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. * Returns created events in order. diff --git a/app/routes/admin._index.tsx b/app/routes/admin._index.tsx index 776cf70..001f2b9 100644 --- a/app/routes/admin._index.tsx +++ b/app/routes/admin._index.tsx @@ -1,9 +1,11 @@ import { Link } from "react-router"; +import { useState } from "react"; import type { Route } from "./+types/admin._index"; import { findAllSports } from "~/models/sport"; import { findAllSportsSeasons } from "~/models/sports-season"; import { findAllSeasonTemplates } from "~/models/season-template"; +import { getEventsForDates } from "~/models/scoring-event"; import { Card, CardContent, @@ -12,17 +14,29 @@ import { CardTitle, } from "~/components/ui/card"; 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 { 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() { - const [sports, sportsSeasons, templates] = await Promise.all([ + const { today, tomorrow } = getTodayAndTomorrowDates(); + + const [sports, sportsSeasons, templates, upcomingEvents] = await Promise.all([ findAllSports(), findAllSportsSeasons(), findAllSeasonTemplates(), + getEventsForDates([today, tomorrow]), ]); return { @@ -31,11 +45,216 @@ export async function loader() { sportsSeasonsCount: sportsSeasons.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 ( + + +
+
+ Games to Score + Scoring events that need your attention +
+ {total > 0 && ( +
+
+ {scored}/{total} +
+

scored

+
+ )} +
+ + {/* Tab selector */} +
+ + +
+
+ + + {displayEvents.length === 0 ? ( +
+ No scoring events scheduled for {activeTab === "today" ? "today" : "tomorrow"}. +
+ ) : ( + <> + {pending > 0 && ( +
+ + {pending} event{pending !== 1 ? "s" : ""} still need{pending === 1 ? "s" : ""} scoring +
+ )} + {pending === 0 && total > 0 && ( +
+ + All events scored for {activeTab === "today" ? "today" : "tomorrow"}! +
+ )} + +
+ {displayEvents.map((event) => ( +
+
+
+ {event.name} + + {getEventTypeLabel(event.eventType)} + +
+
+ {event.sportName} + · + {event.sportsSeasonName} + {event.eventStartsAt && ( + <> + · + {formatEventTime(event.eventStartsAt)} + + )} +
+
+ +
+ {event.isComplete ? ( + + + Scored + + ) : ( + + Pending + + )} + +
+
+ ))} +
+ + )} +
+
+ ); +} + 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 (
@@ -89,6 +308,11 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
+ {/* Games to Score widget — full width */} +
+ +
+