diff --git a/CLAUDE.md b/CLAUDE.md index dcbc641..1af74cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,6 +147,20 @@ const league = await db.query.leagues.findFirst({...}); - Check `clerkId` for ownership validation - Admin checks via `isAdmin` boolean on users table +### Scoring Event Dates + +Game/event dates are stored in **two different places** depending on the event type, and any date-based query must account for both: + +1. **`scoringEvents.eventDate`** (`date` column, `YYYY-MM-DD` string) — set directly on the event. Used for non-bracket events (majors, final standings) and sometimes for bracket events when the admin sets a single date for the whole round. + +2. **`playoffMatchGames.scheduledAt`** (`timestamp` column) — set on individual games within a bracket match. Bracket events (e.g. a playoff series) often have `eventDate = null` on the scoring event itself, with each game's exact time stored here instead. + +**Pattern for date-range queries:** Use a two-step approach: +1. `selectDistinct` event IDs via left joins through `playoffMatches → playoffMatchGames`, filtering with `eventDate IN [dates] OR (scheduledAt >= dayStart AND scheduledAt <= dayEnd)` +2. `findMany` on the matched IDs to load full event data with relations + +See `getEventsForDates` and `getUpcomingEventsForDraftedParticipants` in `app/models/scoring-event.ts` for the reference implementation. All timestamp bounds use UTC (`T00:00:00.000Z` / `T23:59:59.999Z`). + ### Draft Logic The draft system implements snake draft: 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..7097b29 --- /dev/null +++ b/app/models/__tests__/scoring-event-dashboard.test.ts @@ -0,0 +1,259 @@ +/** + * Tests for getEventsForDates — the admin dashboard "Games to Score" query. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── DB mock ────────────────────────────────────────────────────────────────── +// selectDistinct returns a fluent chain; findMany is used in step 2. +const mockFindMany = vi.fn(); +const mockSelectChainBase = { + from: vi.fn(), + leftJoin: vi.fn(), + where: vi.fn(), +}; + +function makeSelectChain(rows: { id: string }[]) { + const chain = { + from: vi.fn().mockReturnThis(), + leftJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(rows), + }; + return chain; +} + +const mockSelectDistinct = vi.fn(); + +const mockDb = { + query: { + scoringEvents: { findMany: mockFindMany }, + }, + selectDistinct: mockSelectDistinct, +}; + +vi.mock("~/database/context", () => ({ + database: () => mockDb, +})); + +vi.mock("~/database/schema", () => ({ + scoringEvents: { + id: "se.id", + eventDate: "se.event_date", + eventType: "se.event_type", + eventStartsAt: "se.event_starts_at", + createdAt: "se.created_at", + }, + playoffMatches: { + scoringEventId: "pm.scoring_event_id", + id: "pm.id", + }, + playoffMatchGames: { + playoffMatchId: "pmg.playoff_match_id", + scheduledAt: "pmg.scheduled_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 }), +})); + +import { getEventsForDates } from "../scoring-event"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeEventRow(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(() => { + vi.clearAllMocks(); + mockSelectDistinct.mockReturnValue(makeSelectChain([])); + }); + + it("returns empty array and skips DB when no dates given", async () => { + const result = await getEventsForDates([]); + expect(result).toEqual([]); + expect(mockSelectDistinct).not.toHaveBeenCalled(); + expect(mockFindMany).not.toHaveBeenCalled(); + }); + + it("returns empty array when selectDistinct finds no matching event IDs", async () => { + mockSelectDistinct.mockReturnValue(makeSelectChain([])); + + const result = await getEventsForDates(["2026-03-20"]); + + expect(result).toEqual([]); + expect(mockFindMany).not.toHaveBeenCalled(); + }); + + it("maps database rows to DashboardScoringEvent shape", async () => { + mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + mockFindMany.mockResolvedValueOnce([ + makeEventRow({ + 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" }, + }, + }), + ]); + + 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 () => { + mockSelectDistinct.mockReturnValue( + makeSelectChain([{ id: "e1" }, { id: "e2" }]) + ); + mockFindMany.mockResolvedValueOnce([ + makeEventRow({ id: "e1", eventDate: "2026-03-20" }), + makeEventRow({ id: "e2", eventDate: "2026-03-21" }), + ]); + + 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 () => { + mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e-done" }])); + mockFindMany.mockResolvedValueOnce([ + makeEventRow({ + id: "e-done", + isComplete: true, + completedAt: new Date("2026-03-20T18:00:00Z"), + }), + ]); + + 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"); + mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + mockFindMany.mockResolvedValueOnce([ + makeEventRow({ id: "e1", eventStartsAt: starts }), + ]); + + const result = await getEventsForDates(["2026-03-20"]); + + expect(result[0].eventStartsAt).toEqual(starts); + }); + + it("uses two DB calls: selectDistinct then findMany", async () => { + mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1" })]); + + await getEventsForDates(["2026-03-20"]); + + expect(mockSelectDistinct).toHaveBeenCalledOnce(); + expect(mockFindMany).toHaveBeenCalledOnce(); + }); + + it("passes event IDs from step 1 into findMany in step 2", async () => { + mockSelectDistinct.mockReturnValue( + makeSelectChain([{ id: "abc" }, { id: "def" }]) + ); + mockFindMany.mockResolvedValueOnce([]); + + await getEventsForDates(["2026-03-20"]); + + const findManyArgs = mockFindMany.mock.calls[0][0]; + const whereStr = JSON.stringify(findManyArgs.where); + expect(whereStr).toContain("abc"); + expect(whereStr).toContain("def"); + }); + + it("uses leftJoin so bracket events without playoffMatchGames still qualify via eventDate", async () => { + const chain = makeSelectChain([]); + mockSelectDistinct.mockReturnValue(chain); + + await getEventsForDates(["2026-03-20"]); + + expect(chain.leftJoin).toHaveBeenCalled(); + }); + + it("includes both eventDate and scheduledAt timestamp bounds in the where clause", async () => { + const chain = makeSelectChain([]); + mockSelectDistinct.mockReturnValue(chain); + + await getEventsForDates(["2026-03-20", "2026-03-21"]); + + // The where clause is the first (and only) arg to chain.where + const whereArg = JSON.stringify((chain.where as ReturnType).mock.calls[0][0]); + // eventDate inArray check + expect(whereArg).toContain("2026-03-20"); + expect(whereArg).toContain("2026-03-21"); + // timestamp bounds for scheduledAt + expect(whereArg).toContain("T00:00:00.000Z"); + expect(whereArg).toContain("T23:59:59.999Z"); + }); + + it("excludes schedule_event type via the where clause", async () => { + const chain = makeSelectChain([]); + mockSelectDistinct.mockReturnValue(chain); + + await getEventsForDates(["2026-03-20"]); + + const whereArg = JSON.stringify((chain.where as ReturnType).mock.calls[0][0]); + expect(whereArg).toContain("playoff_game"); + expect(whereArg).toContain("major_tournament"); + expect(whereArg).toContain("final_standings"); + expect(whereArg).not.toContain("schedule_event"); + }); +}); diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 78b8a04..8ca14cb 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -544,6 +544,111 @@ 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. + * + * An event is included if EITHER its own eventDate falls on one of the given + * dates, OR any of its playoffMatchGames has a scheduledAt timestamp on one of + * those dates. This ensures bracket events where the admin set dates on + * individual games (rather than the event itself) are still surfaced. + */ +export async function getEventsForDates( + dates: string[], + providedDb?: ReturnType +): Promise { + if (dates.length === 0) return []; + + const db = providedDb || database(); + + // Build timestamp bounds covering the full span of the requested dates so we + // can range-check the playoffMatchGames.scheduledAt timestamp column. + const sortedDates = [...dates].sort(); + const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z"); + const dateToTimestamp = new Date(sortedDates[sortedDates.length - 1] + "T23:59:59.999Z"); + + // Step 1: collect distinct event IDs where the event date OR any game's + // scheduledAt falls within the requested dates. + const rows = await db + .selectDistinct({ id: schema.scoringEvents.id }) + .from(schema.scoringEvents) + .leftJoin( + schema.playoffMatches, + eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id) + ) + .leftJoin( + schema.playoffMatchGames, + eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id) + ) + .where( + and( + inArray(schema.scoringEvents.eventType, [ + "playoff_game", + "major_tournament", + "final_standings", + ]), + or( + inArray(schema.scoringEvents.eventDate, dates), + and( + isNotNull(schema.playoffMatchGames.scheduledAt), + gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp), + lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp) + ) + ) + ) + ); + + if (rows.length === 0) return []; + + const eventIds = rows.map((r) => r.id); + + // Step 2: fetch the matched events with sport season + sport info. + const events = await db.query.scoringEvents.findMany({ + where: inArray(schema.scoringEvents.id, eventIds), + 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 */} +
+ +
+