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>
This commit is contained in:
Chris Parsons 2026-03-25 20:46:54 -07:00 committed by GitHub
parent 2b48ef285d
commit eca1508b3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 160 additions and 52 deletions

View file

@ -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");
});
});

View file

@ -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<string | null>`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<string, string | null>();
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,

View file

@ -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<number> {
const db = database();
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.seasonTemplates);
return count;
}
export type SeasonTemplate = typeof schema.seasonTemplates.$inferSelect;
export type NewSeasonTemplate = typeof schema.seasonTemplates.$inferInsert;

View file

@ -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<Season> {
const newInviteCode = generateInviteCode();
return await updateSeason(seasonId, { inviteCode: newInviteCode });
}
export async function countSeasonsByStatus(): Promise<Record<SeasonStatus, number>> {
const db = database();
const rows = await db
.select({ status: schema.seasons.status, count: sql<number>`count(*)::int` })
.from(schema.seasons)
.groupBy(schema.seasons.status);
const result: Record<SeasonStatus, number> = {
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;
}

View file

@ -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<number> {
const db = database();
const [{ count }] = await db
.select({ count: sql<number>`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";

View file

@ -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<number> {
const db = database();
const [{ count }] = await db
.select({ count: sql<number>`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 & {

View file

@ -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 && (
<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}
{todayEvents.filter((e) => !e.isComplete).length}
</span>
)}
</button>
@ -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 (
<div className="p-8">
<div className="mb-8">
@ -268,7 +271,31 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
</p>
</div>
<div className="grid gap-6 md:grid-cols-3 mb-8">
<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>
@ -350,6 +377,7 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
</CardContent>
</Card>
{!systemIsPopulated && (
<Card>
<CardHeader>
<CardTitle>Getting Started</CardTitle>
@ -402,6 +430,7 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
</div>
</CardContent>
</Card>
)}
</div>
</div>
);