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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 & {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue