From eca1508b3f14030c0e6fcc72f21d430cf053c405 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:46:54 -0700 Subject: [PATCH] Improve admin dashboard stats and fix scoring event bugs (#230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/scoring-event-dashboard.test.ts | 61 ++++++++++------ app/models/scoring-event.ts | 25 +++++-- app/models/season-template.ts | 10 ++- app/models/season.ts | 23 +++++- app/models/sport.ts | 10 ++- app/models/sports-season.ts | 10 ++- app/routes/admin._index.tsx | 73 +++++++++++++------ 7 files changed, 160 insertions(+), 52 deletions(-) diff --git a/app/models/__tests__/scoring-event-dashboard.test.ts b/app/models/__tests__/scoring-event-dashboard.test.ts index a8de849..214c688 100644 --- a/app/models/__tests__/scoring-event-dashboard.test.ts +++ b/app/models/__tests__/scoring-event-dashboard.test.ts @@ -4,10 +4,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // ── DB mock ────────────────────────────────────────────────────────────────── -// selectDistinct returns a fluent chain; findMany is used in step 2. +// select returns a fluent chain; findMany is used in step 2. const mockFindMany = vi.fn(); -function makeSelectChain(rows: { id: string }[]) { +function makeSelectChain(rows: { id: string; eventDate?: string | null; gameDate?: string | null }[]) { const chain = { from: vi.fn().mockReturnThis(), leftJoin: vi.fn().mockReturnThis(), @@ -16,13 +16,13 @@ function makeSelectChain(rows: { id: string }[]) { return chain; } -const mockSelectDistinct = vi.fn(); +const mockSelect = vi.fn(); const mockDb = { query: { scoringEvents: { findMany: mockFindMany }, }, - selectDistinct: mockSelectDistinct, + select: mockSelect, }; vi.mock("~/database/context", () => ({ @@ -57,6 +57,7 @@ vi.mock("drizzle-orm", () => ({ or: (...args: unknown[]) => ({ type: "or", args }), inArray: (col: unknown, arr: unknown) => ({ type: "inArray", col, arr }), isNotNull: (col: unknown) => ({ type: "isNotNull", col }), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: "sql", strings, values }), })); import { getEventsForDates } from "../scoring-event"; @@ -95,18 +96,18 @@ function makeEventRow(overrides: Partial<{ describe("getEventsForDates", () => { beforeEach(() => { vi.clearAllMocks(); - mockSelectDistinct.mockReturnValue(makeSelectChain([])); + mockSelect.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(mockSelect).not.toHaveBeenCalled(); expect(mockFindMany).not.toHaveBeenCalled(); }); - it("returns empty array when selectDistinct finds no matching event IDs", async () => { - mockSelectDistinct.mockReturnValue(makeSelectChain([])); + it("returns empty array when select finds no matching event IDs", async () => { + mockSelect.mockReturnValue(makeSelectChain([])); const result = await getEventsForDates(["2026-03-20"]); @@ -115,7 +116,7 @@ describe("getEventsForDates", () => { }); it("maps database rows to DashboardScoringEvent shape", async () => { - mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }])); mockFindMany.mockResolvedValueOnce([ makeEventRow({ id: "e1", @@ -148,8 +149,8 @@ describe("getEventsForDates", () => { }); it("returns events for multiple dates", async () => { - mockSelectDistinct.mockReturnValue( - makeSelectChain([{ id: "e1" }, { id: "e2" }]) + mockSelect.mockReturnValue( + makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }, { id: "e2", eventDate: "2026-03-21", gameDate: null }]) ); mockFindMany.mockResolvedValueOnce([ makeEventRow({ id: "e1", eventDate: "2026-03-20" }), @@ -163,7 +164,7 @@ describe("getEventsForDates", () => { }); it("includes completed events with isComplete: true", async () => { - mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e-done" }])); + mockSelect.mockReturnValue(makeSelectChain([{ id: "e-done", eventDate: "2026-03-20", gameDate: null }])); mockFindMany.mockResolvedValueOnce([ makeEventRow({ id: "e-done", @@ -180,7 +181,7 @@ describe("getEventsForDates", () => { it("includes eventStartsAt when present", async () => { const starts = new Date("2026-03-20T19:30:00Z"); - mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }])); mockFindMany.mockResolvedValueOnce([ makeEventRow({ id: "e1", eventStartsAt: starts }), ]); @@ -190,19 +191,19 @@ describe("getEventsForDates", () => { expect(result[0].eventStartsAt).toEqual(starts); }); - it("uses two DB calls: selectDistinct then findMany", async () => { - mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }])); + it("uses two DB calls: select then findMany", async () => { + mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }])); mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1" })]); await getEventsForDates(["2026-03-20"]); - expect(mockSelectDistinct).toHaveBeenCalledOnce(); + expect(mockSelect).toHaveBeenCalledOnce(); expect(mockFindMany).toHaveBeenCalledOnce(); }); it("passes event IDs from step 1 into findMany in step 2", async () => { - mockSelectDistinct.mockReturnValue( - makeSelectChain([{ id: "abc" }, { id: "def" }]) + mockSelect.mockReturnValue( + makeSelectChain([{ id: "abc", eventDate: "2026-03-20", gameDate: null }, { id: "def", eventDate: "2026-03-20", gameDate: null }]) ); mockFindMany.mockResolvedValueOnce([]); @@ -216,7 +217,7 @@ describe("getEventsForDates", () => { it("uses leftJoin so bracket events without playoffMatchGames still qualify via eventDate", async () => { const chain = makeSelectChain([]); - mockSelectDistinct.mockReturnValue(chain); + mockSelect.mockReturnValue(chain); await getEventsForDates(["2026-03-20"]); @@ -225,7 +226,7 @@ describe("getEventsForDates", () => { it("includes both eventDate and scheduledAt timestamp bounds in the where clause", async () => { const chain = makeSelectChain([]); - mockSelectDistinct.mockReturnValue(chain); + mockSelect.mockReturnValue(chain); await getEventsForDates(["2026-03-20", "2026-03-21"]); @@ -241,7 +242,7 @@ describe("getEventsForDates", () => { it("excludes schedule_event type via the where clause", async () => { const chain = makeSelectChain([]); - mockSelectDistinct.mockReturnValue(chain); + mockSelect.mockReturnValue(chain); await getEventsForDates(["2026-03-20"]); @@ -251,4 +252,22 @@ describe("getEventsForDates", () => { expect(whereArg).toContain("final_standings"); expect(whereArg).not.toContain("schedule_event"); }); + + it("sets displayDate from eventDate when present", async () => { + mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: "2026-03-20", gameDate: null }])); + mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1", eventDate: "2026-03-20" })]); + + const result = await getEventsForDates(["2026-03-20"]); + + expect(result[0].displayDate).toBe("2026-03-20"); + }); + + it("sets displayDate from gameDate when eventDate is null", async () => { + mockSelect.mockReturnValue(makeSelectChain([{ id: "e1", eventDate: null, gameDate: "2026-03-20" }])); + mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1", eventDate: null })]); + + const result = await getEventsForDates(["2026-03-20"]); + + expect(result[0].displayDate).toBe("2026-03-20"); + }); }); diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 6120309..2e5e9e5 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -1,6 +1,6 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull } from "drizzle-orm"; +import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm"; import type { BracketRegion } from "~/lib/bracket-templates"; import { recalculateAffectedLeagues } from "./scoring-calculator"; import { recalculateParticipantQP } from "./qualifying-points"; @@ -592,6 +592,8 @@ export interface DashboardScoringEvent { id: string; name: string; eventDate: string | null; + /** Resolved date for tab bucketing: eventDate if set, else earliest matched game date. */ + displayDate: string | null; eventStartsAt: Date | null; eventType: string; isComplete: boolean; @@ -626,10 +628,15 @@ export async function getEventsForDates( 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. + // Step 1: collect event IDs where the event date OR any game's scheduledAt + // falls within the requested dates. Also capture the matched date so we can + // bucket events that have no eventDate (bracket events) into the right tab. const rows = await db - .selectDistinct({ id: schema.scoringEvents.id }) + .select({ + id: schema.scoringEvents.id, + eventDate: schema.scoringEvents.eventDate, + gameDate: sql`DATE(${schema.playoffMatchGames.scheduledAt} AT TIME ZONE 'UTC')`, + }) .from(schema.scoringEvents) .leftJoin( schema.playoffMatches, @@ -659,7 +666,14 @@ export async function getEventsForDates( if (rows.length === 0) return []; - const eventIds = rows.map((r) => r.id); + // Deduplicate: keep first matched row per event to derive displayDate. + const displayDateMap = new Map(); + for (const row of rows) { + if (!displayDateMap.has(row.id)) { + displayDateMap.set(row.id, row.eventDate ?? row.gameDate ?? null); + } + } + const eventIds = [...displayDateMap.keys()]; // Step 2: fetch the matched events with sport season + sport info. const events = await db.query.scoringEvents.findMany({ @@ -682,6 +696,7 @@ export async function getEventsForDates( id: e.id, name: e.name, eventDate: e.eventDate, + displayDate: displayDateMap.get(e.id) ?? e.eventDate ?? null, eventStartsAt: e.eventStartsAt, eventType: e.eventType, isComplete: e.isComplete, diff --git a/app/models/season-template.ts b/app/models/season-template.ts index c6f6638..dd7cf1d 100644 --- a/app/models/season-template.ts +++ b/app/models/season-template.ts @@ -1,7 +1,15 @@ -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; +export async function countSeasonTemplates(): Promise { + const db = database(); + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(schema.seasonTemplates); + return count; +} + export type SeasonTemplate = typeof schema.seasonTemplates.$inferSelect; export type NewSeasonTemplate = typeof schema.seasonTemplates.$inferInsert; diff --git a/app/models/season.ts b/app/models/season.ts index 1b4780f..69b4c26 100644 --- a/app/models/season.ts +++ b/app/models/season.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; @@ -186,3 +186,24 @@ export async function regenerateInviteCode(seasonId: string): Promise { const newInviteCode = generateInviteCode(); return await updateSeason(seasonId, { inviteCode: newInviteCode }); } + +export async function countSeasonsByStatus(): Promise> { + const db = database(); + const rows = await db + .select({ status: schema.seasons.status, count: sql`count(*)::int` }) + .from(schema.seasons) + .groupBy(schema.seasons.status); + + const result: Record = { + pre_draft: 0, + draft: 0, + active: 0, + completed: 0, + }; + for (const row of rows) { + if (row.status in result) { + result[row.status as SeasonStatus] = row.count; + } + } + return result; +} diff --git a/app/models/sport.ts b/app/models/sport.ts index be39e8e..959240f 100644 --- a/app/models/sport.ts +++ b/app/models/sport.ts @@ -1,7 +1,15 @@ -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; +export async function countSports(): Promise { + const db = database(); + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(schema.sports); + return count; +} + export type Sport = typeof schema.sports.$inferSelect; export type NewSport = typeof schema.sports.$inferInsert; export type SportType = "team" | "individual"; diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index d2557d0..f650f4a 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -1,7 +1,15 @@ -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; +export async function countSportsSeasons(): Promise { + const db = database(); + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(schema.sportsSeasons); + return count; +} + export type SportsSeason = typeof schema.sportsSeasons.$inferSelect; export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert; export type SportsSeasonWithSport = SportsSeason & { diff --git a/app/routes/admin._index.tsx b/app/routes/admin._index.tsx index 1dd8759..e9f5c8e 100644 --- a/app/routes/admin._index.tsx +++ b/app/routes/admin._index.tsx @@ -2,10 +2,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 { 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, @@ -15,7 +16,7 @@ import { } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; -import { Trophy, Calendar, FolderKanban, ArrowRight, CheckCircle2, Clock, ExternalLink } from "lucide-react"; +import { Trophy, Calendar, FolderKanban, ArrowRight, CheckCircle2, Clock, ExternalLink, Users } from "lucide-react"; export function meta(): Route.MetaDescriptors { return [{ title: "Admin - Brackt" }]; @@ -35,19 +36,18 @@ function getTodayAndTomorrowDates() { export async function loader() { const { today, tomorrow } = getTodayAndTomorrowDates(); - const [sports, sportsSeasons, templates, upcomingEvents] = await Promise.all([ - findAllSports(), - findAllSportsSeasons(), - findAllSeasonTemplates(), - getEventsForDates([today, tomorrow]), - ]); + const [sportsCount, sportsSeasonsCount, templatesCount, upcomingEvents, seasonStatusCounts] = + await Promise.all([ + countSports(), + countSportsSeasons(), + countSeasonTemplates(), + getEventsForDates([today, tomorrow]), + countSeasonsByStatus(), + ]); return { - stats: { - sportsCount: sports.length, - sportsSeasonsCount: sportsSeasons.length, - templatesCount: templates.length, - }, + stats: { sportsCount, sportsSeasonsCount, templatesCount }, + seasonStatusCounts, upcomingEvents, today, tomorrow, @@ -57,7 +57,7 @@ export async function loader() { function formatEventTime(eventStartsAt: string | null): string | null { if (!eventStartsAt) return null; const d = new Date(eventStartsAt); - return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); } function getEventTypeLabel(eventType: string): string { @@ -73,6 +73,7 @@ type DashboardEvent = { id: string; name: string; eventDate: string | null; + displayDate: string | null; eventStartsAt: string | null; eventType: string; isComplete: boolean; @@ -94,8 +95,8 @@ function GamesToScore({ }) { const [activeTab, setActiveTab] = useState<"today" | "tomorrow">("today"); - const todayEvents = events.filter((e) => e.eventDate === today); - const tomorrowEvents = events.filter((e) => e.eventDate === tomorrow); + 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; @@ -131,13 +132,13 @@ function GamesToScore({ }`} > Today - {todayEvents.length > 0 && ( + {todayEvents.filter((e) => !e.isComplete).length > 0 && ( - {todayEvents.filter((e) => !e.isComplete).length || todayEvents.length} + {todayEvents.filter((e) => !e.isComplete).length} )} @@ -250,7 +251,7 @@ function GamesToScore({ } export default function AdminDashboard({ loaderData }: Route.ComponentProps) { - const { stats, upcomingEvents, today, tomorrow } = loaderData; + const { stats, seasonStatusCounts, upcomingEvents, today, tomorrow } = loaderData; // Serialize dates to strings for the client component const serializedEvents: DashboardEvent[] = upcomingEvents.map((e) => ({ @@ -259,6 +260,8 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) { completedAt: e.completedAt ? new Date(e.completedAt).toISOString() : null, })); + const systemIsPopulated = stats.sportsCount > 0; + return (
@@ -268,7 +271,31 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {

-
+
+ + + Seasons by Status + + + +
+ {( + [ + { key: "pre_draft", label: "Pre-Draft" }, + { key: "draft", label: "Drafting" }, + { key: "active", label: "Active" }, + { key: "completed", label: "Completed" }, + ] as const + ).map(({ key, label }) => ( +
+ {label} + {seasonStatusCounts[key]} +
+ ))} +
+
+
+ Total Sports @@ -350,6 +377,7 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) { + {!systemIsPopulated && ( Getting Started @@ -402,6 +430,7 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
+ )}
);