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>
This commit is contained in:
Chris Parsons 2026-03-25 20:35:49 -07:00
parent 2e2d8db777
commit fac61fdcdf
6 changed files with 120 additions and 31 deletions

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; 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 type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator"; import { recalculateAffectedLeagues } from "./scoring-calculator";
import { recalculateParticipantQP } from "./qualifying-points"; import { recalculateParticipantQP } from "./qualifying-points";
@ -592,6 +592,8 @@ export interface DashboardScoringEvent {
id: string; id: string;
name: string; name: string;
eventDate: string | null; eventDate: string | null;
/** Resolved date for tab bucketing: eventDate if set, else earliest matched game date. */
displayDate: string | null;
eventStartsAt: Date | null; eventStartsAt: Date | null;
eventType: string; eventType: string;
isComplete: boolean; isComplete: boolean;
@ -626,10 +628,15 @@ export async function getEventsForDates(
const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z"); const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z");
const dateToTimestamp = new Date(sortedDates[sortedDates.length - 1] + "T23:59:59.999Z"); 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 // Step 1: collect event IDs where the event date OR any game's scheduledAt
// scheduledAt falls within the requested dates. // 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 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) .from(schema.scoringEvents)
.leftJoin( .leftJoin(
schema.playoffMatches, schema.playoffMatches,
@ -659,7 +666,14 @@ export async function getEventsForDates(
if (rows.length === 0) return []; 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. // Step 2: fetch the matched events with sport season + sport info.
const events = await db.query.scoringEvents.findMany({ const events = await db.query.scoringEvents.findMany({
@ -682,6 +696,7 @@ export async function getEventsForDates(
id: e.id, id: e.id,
name: e.name, name: e.name,
eventDate: e.eventDate, eventDate: e.eventDate,
displayDate: displayDateMap.get(e.id) ?? e.eventDate ?? null,
eventStartsAt: e.eventStartsAt, eventStartsAt: e.eventStartsAt,
eventType: e.eventType, eventType: e.eventType,
isComplete: e.isComplete, 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 { database } from "~/database/context";
import * as schema from "~/database/schema"; 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 SeasonTemplate = typeof schema.seasonTemplates.$inferSelect;
export type NewSeasonTemplate = typeof schema.seasonTemplates.$inferInsert; 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 { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -186,3 +186,24 @@ export async function regenerateInviteCode(seasonId: string): Promise<Season> {
const newInviteCode = generateInviteCode(); const newInviteCode = generateInviteCode();
return await updateSeason(seasonId, { inviteCode: newInviteCode }); 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 { database } from "~/database/context";
import * as schema from "~/database/schema"; 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 Sport = typeof schema.sports.$inferSelect;
export type NewSport = typeof schema.sports.$inferInsert; export type NewSport = typeof schema.sports.$inferInsert;
export type SportType = "team" | "individual"; 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 { database } from "~/database/context";
import * as schema from "~/database/schema"; 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 SportsSeason = typeof schema.sportsSeasons.$inferSelect;
export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert; export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert;
export type SportsSeasonWithSport = SportsSeason & { export type SportsSeasonWithSport = SportsSeason & {

View file

@ -2,10 +2,11 @@ import { Link } from "react-router";
import { useState } from "react"; import { useState } from "react";
import type { Route } from "./+types/admin._index"; import type { Route } from "./+types/admin._index";
import { findAllSports } from "~/models/sport"; import { countSports } from "~/models/sport";
import { findAllSportsSeasons } from "~/models/sports-season"; import { countSportsSeasons } from "~/models/sports-season";
import { findAllSeasonTemplates } from "~/models/season-template"; import { countSeasonTemplates } from "~/models/season-template";
import { getEventsForDates } from "~/models/scoring-event"; import { getEventsForDates } from "~/models/scoring-event";
import { countSeasonsByStatus } from "~/models/season";
import { import {
Card, Card,
CardContent, CardContent,
@ -15,7 +16,7 @@ import {
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge"; 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 { export function meta(): Route.MetaDescriptors {
return [{ title: "Admin - Brackt" }]; return [{ title: "Admin - Brackt" }];
@ -35,19 +36,18 @@ function getTodayAndTomorrowDates() {
export async function loader() { export async function loader() {
const { today, tomorrow } = getTodayAndTomorrowDates(); const { today, tomorrow } = getTodayAndTomorrowDates();
const [sports, sportsSeasons, templates, upcomingEvents] = await Promise.all([ const [sportsCount, sportsSeasonsCount, templatesCount, upcomingEvents, seasonStatusCounts] =
findAllSports(), await Promise.all([
findAllSportsSeasons(), countSports(),
findAllSeasonTemplates(), countSportsSeasons(),
getEventsForDates([today, tomorrow]), countSeasonTemplates(),
]); getEventsForDates([today, tomorrow]),
countSeasonsByStatus(),
]);
return { return {
stats: { stats: { sportsCount, sportsSeasonsCount, templatesCount },
sportsCount: sports.length, seasonStatusCounts,
sportsSeasonsCount: sportsSeasons.length,
templatesCount: templates.length,
},
upcomingEvents, upcomingEvents,
today, today,
tomorrow, tomorrow,
@ -57,7 +57,7 @@ export async function loader() {
function formatEventTime(eventStartsAt: string | null): string | null { function formatEventTime(eventStartsAt: string | null): string | null {
if (!eventStartsAt) return null; if (!eventStartsAt) return null;
const d = new Date(eventStartsAt); 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 { function getEventTypeLabel(eventType: string): string {
@ -73,6 +73,7 @@ type DashboardEvent = {
id: string; id: string;
name: string; name: string;
eventDate: string | null; eventDate: string | null;
displayDate: string | null;
eventStartsAt: string | null; eventStartsAt: string | null;
eventType: string; eventType: string;
isComplete: boolean; isComplete: boolean;
@ -94,8 +95,8 @@ function GamesToScore({
}) { }) {
const [activeTab, setActiveTab] = useState<"today" | "tomorrow">("today"); const [activeTab, setActiveTab] = useState<"today" | "tomorrow">("today");
const todayEvents = events.filter((e) => e.eventDate === today); const todayEvents = events.filter((e) => e.displayDate === today);
const tomorrowEvents = events.filter((e) => e.eventDate === tomorrow); const tomorrowEvents = events.filter((e) => e.displayDate === tomorrow);
const displayEvents = activeTab === "today" ? todayEvents : tomorrowEvents; const displayEvents = activeTab === "today" ? todayEvents : tomorrowEvents;
const scored = displayEvents.filter((e) => e.isComplete).length; const scored = displayEvents.filter((e) => e.isComplete).length;
@ -131,13 +132,13 @@ function GamesToScore({
}`} }`}
> >
Today 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 ${ <span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs ${
activeTab === "today" activeTab === "today"
? "bg-primary-foreground/20 text-primary-foreground" ? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
}`}> }`}>
{todayEvents.filter((e) => !e.isComplete).length || todayEvents.length} {todayEvents.filter((e) => !e.isComplete).length}
</span> </span>
)} )}
</button> </button>
@ -250,7 +251,7 @@ function GamesToScore({
} }
export default function AdminDashboard({ loaderData }: Route.ComponentProps) { 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 // Serialize dates to strings for the client component
const serializedEvents: DashboardEvent[] = upcomingEvents.map((e) => ({ 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, completedAt: e.completedAt ? new Date(e.completedAt).toISOString() : null,
})); }));
const systemIsPopulated = stats.sportsCount > 0;
return ( return (
<div className="p-8"> <div className="p-8">
<div className="mb-8"> <div className="mb-8">
@ -268,7 +271,31 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
</p> </p>
</div> </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> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sports</CardTitle> <CardTitle className="text-sm font-medium">Total Sports</CardTitle>
@ -350,6 +377,7 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
</CardContent> </CardContent>
</Card> </Card>
{!systemIsPopulated && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Getting Started</CardTitle> <CardTitle>Getting Started</CardTitle>
@ -402,6 +430,7 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)}
</div> </div>
</div> </div>
); );