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:
parent
2b48ef285d
commit
eca1508b3f
7 changed files with 160 additions and 52 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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