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:
parent
2e2d8db777
commit
fac61fdcdf
6 changed files with 120 additions and 31 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue