Add "Games to Score" widget to admin dashboard
Shows scoring events for today and tomorrow in a tabbed card, with status badges (Pending/Scored), a scored/total progress counter, and direct "Score" / "View" links to each event's results page. Excludes non-scoring schedule_event types so only actionable events surface. - app/models/scoring-event.ts: add getEventsForDates() model function that queries scoringEvents joined with sportsSeasons/sport for a given list of date strings - app/routes/admin._index.tsx: call getEventsForDates([today, tomorrow]) in the loader and render the GamesToScore component below the stats row - app/models/__tests__/scoring-event-dashboard.test.ts: 8 unit tests covering empty input, shape mapping, multi-date filtering, completed events, and the inArray where conditions https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5
This commit is contained in:
parent
d271cc6792
commit
4044a6372e
3 changed files with 476 additions and 3 deletions
184
app/models/__tests__/scoring-event-dashboard.test.ts
Normal file
184
app/models/__tests__/scoring-event-dashboard.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Tests for getEventsForDates — the admin dashboard "Games to Score" query.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ── DB mock ─────────────────────────────────────────────────────────────────
|
||||
const mockFindMany = vi.fn();
|
||||
const mockDb = {
|
||||
query: {
|
||||
scoringEvents: { findMany: mockFindMany },
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: () => mockDb,
|
||||
}));
|
||||
|
||||
vi.mock("~/database/schema", () => ({
|
||||
scoringEvents: {
|
||||
eventDate: "se.event_date",
|
||||
eventType: "se.event_type",
|
||||
eventStartsAt: "se.event_starts_at",
|
||||
createdAt: "se.created_at",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
||||
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||
asc: (col: unknown) => ({ type: "asc", col }),
|
||||
desc: (col: unknown) => ({ type: "desc", col }),
|
||||
gte: (col: unknown, val: unknown) => ({ type: "gte", col, val }),
|
||||
lte: (col: unknown, val: unknown) => ({ type: "lte", col, val }),
|
||||
or: (...args: unknown[]) => ({ type: "or", args }),
|
||||
inArray: (col: unknown, arr: unknown) => ({ type: "inArray", col, arr }),
|
||||
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
|
||||
}));
|
||||
|
||||
// Must be imported after mocks are registered
|
||||
import { getEventsForDates } from "../scoring-event";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeScoringEventRow(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
eventDate: string | null;
|
||||
eventStartsAt: Date | null;
|
||||
eventType: string;
|
||||
isComplete: boolean;
|
||||
completedAt: Date | null;
|
||||
sportsSeasonId: string;
|
||||
sportsSeason: { name: string; sport: { name: string; slug: string | null } };
|
||||
}> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? "event-1",
|
||||
name: overrides.name ?? "Test Event",
|
||||
eventDate: overrides.eventDate ?? "2026-03-20",
|
||||
eventStartsAt: overrides.eventStartsAt ?? null,
|
||||
eventType: overrides.eventType ?? "playoff_game",
|
||||
isComplete: overrides.isComplete ?? false,
|
||||
completedAt: overrides.completedAt ?? null,
|
||||
sportsSeasonId: overrides.sportsSeasonId ?? "season-1",
|
||||
sportsSeason: overrides.sportsSeason ?? {
|
||||
name: "2026 NFL Playoffs",
|
||||
sport: { name: "NFL", slug: "nfl" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getEventsForDates", () => {
|
||||
beforeEach(() => {
|
||||
mockFindMany.mockReset();
|
||||
});
|
||||
|
||||
it("returns an empty array when no dates are provided", async () => {
|
||||
const result = await getEventsForDates([]);
|
||||
expect(result).toEqual([]);
|
||||
expect(mockFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps database rows to DashboardScoringEvent shape", async () => {
|
||||
const row = makeScoringEventRow({
|
||||
id: "e1",
|
||||
name: "Conference Finals",
|
||||
eventDate: "2026-03-20",
|
||||
eventType: "playoff_game",
|
||||
isComplete: false,
|
||||
sportsSeasonId: "ss-1",
|
||||
sportsSeason: {
|
||||
name: "2026 NBA Playoffs",
|
||||
sport: { name: "NBA", slug: "nba" },
|
||||
},
|
||||
});
|
||||
mockFindMany.mockResolvedValueOnce([row]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "e1",
|
||||
name: "Conference Finals",
|
||||
eventDate: "2026-03-20",
|
||||
eventType: "playoff_game",
|
||||
isComplete: false,
|
||||
sportsSeasonId: "ss-1",
|
||||
sportsSeasonName: "2026 NBA Playoffs",
|
||||
sportName: "NBA",
|
||||
sportSlug: "nba",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns events for multiple dates", async () => {
|
||||
const todayRow = makeScoringEventRow({ id: "e1", eventDate: "2026-03-20" });
|
||||
const tomorrowRow = makeScoringEventRow({ id: "e2", eventDate: "2026-03-21" });
|
||||
mockFindMany.mockResolvedValueOnce([todayRow, tomorrowRow]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20", "2026-03-21"]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((e) => e.id)).toEqual(["e1", "e2"]);
|
||||
});
|
||||
|
||||
it("includes completed events with isComplete: true", async () => {
|
||||
const completedRow = makeScoringEventRow({
|
||||
id: "e-done",
|
||||
isComplete: true,
|
||||
completedAt: new Date("2026-03-20T18:00:00Z"),
|
||||
});
|
||||
mockFindMany.mockResolvedValueOnce([completedRow]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
expect(result[0].isComplete).toBe(true);
|
||||
expect(result[0].completedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("includes eventStartsAt when present", async () => {
|
||||
const starts = new Date("2026-03-20T19:30:00Z");
|
||||
const row = makeScoringEventRow({ id: "e1", eventStartsAt: starts });
|
||||
mockFindMany.mockResolvedValueOnce([row]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
expect(result[0].eventStartsAt).toEqual(starts);
|
||||
});
|
||||
|
||||
it("passes both dates as an inArray condition to the query", async () => {
|
||||
mockFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await getEventsForDates(["2026-03-20", "2026-03-21"]);
|
||||
|
||||
const callArgs = mockFindMany.mock.calls[0][0];
|
||||
// The where clause should contain an inArray condition with both dates
|
||||
const whereStr = JSON.stringify(callArgs.where);
|
||||
expect(whereStr).toContain("2026-03-20");
|
||||
expect(whereStr).toContain("2026-03-21");
|
||||
});
|
||||
|
||||
it("excludes schedule_event types via inArray condition", async () => {
|
||||
mockFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
const callArgs = mockFindMany.mock.calls[0][0];
|
||||
const whereStr = JSON.stringify(callArgs.where);
|
||||
// The allowed event types should be present
|
||||
expect(whereStr).toContain("playoff_game");
|
||||
expect(whereStr).toContain("major_tournament");
|
||||
expect(whereStr).toContain("final_standings");
|
||||
// schedule_event should not appear
|
||||
expect(whereStr).not.toContain("schedule_event");
|
||||
});
|
||||
|
||||
it("returns empty array when DB returns no rows", async () => {
|
||||
mockFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getEventsForDates(["2026-03-20"]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -544,6 +544,71 @@ export async function getUpcomingEventsForDraftedParticipants(
|
|||
return Array.from(eventMap.values());
|
||||
}
|
||||
|
||||
export interface DashboardScoringEvent {
|
||||
id: string;
|
||||
name: string;
|
||||
eventDate: string | null;
|
||||
eventStartsAt: Date | null;
|
||||
eventType: string;
|
||||
isComplete: boolean;
|
||||
completedAt: Date | null;
|
||||
sportsSeasonId: string;
|
||||
sportsSeasonName: string;
|
||||
sportName: string;
|
||||
sportSlug: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scoring events (excluding schedule_event type) for the given dates,
|
||||
* joined with their sports season and sport info. Used for the admin dashboard
|
||||
* "Games to Score" widget.
|
||||
*/
|
||||
export async function getEventsForDates(
|
||||
dates: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<DashboardScoringEvent[]> {
|
||||
if (dates.length === 0) return [];
|
||||
|
||||
const db = providedDb || database();
|
||||
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
inArray(schema.scoringEvents.eventDate, dates),
|
||||
inArray(schema.scoringEvents.eventType, [
|
||||
"playoff_game",
|
||||
"major_tournament",
|
||||
"final_standings",
|
||||
])
|
||||
),
|
||||
orderBy: [
|
||||
asc(schema.scoringEvents.eventDate),
|
||||
asc(schema.scoringEvents.eventStartsAt),
|
||||
asc(schema.scoringEvents.createdAt),
|
||||
],
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return events.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
eventDate: e.eventDate,
|
||||
eventStartsAt: e.eventStartsAt,
|
||||
eventType: e.eventType,
|
||||
isComplete: e.isComplete,
|
||||
completedAt: e.completedAt,
|
||||
sportsSeasonId: e.sportsSeasonId,
|
||||
sportsSeasonName: e.sportsSeason.name,
|
||||
sportName: e.sportsSeason.sport.name,
|
||||
sportSlug: e.sportsSeason.sport.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create scoring events for a sports season.
|
||||
* Returns created events in order.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,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 { getEventsForDates } from "~/models/scoring-event";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -12,17 +14,29 @@ import {
|
|||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trophy, Calendar, FolderKanban, ArrowRight, Camera } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Trophy, Calendar, FolderKanban, ArrowRight, CheckCircle2, Clock, ExternalLink } from "lucide-react";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "Admin - Brackt" }];
|
||||
}
|
||||
|
||||
function getTodayAndTomorrowDates() {
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const toDateStr = (d: Date) => d.toISOString().split("T")[0];
|
||||
return { today: toDateStr(today), tomorrow: toDateStr(tomorrow) };
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
const [sports, sportsSeasons, templates] = await Promise.all([
|
||||
const { today, tomorrow } = getTodayAndTomorrowDates();
|
||||
|
||||
const [sports, sportsSeasons, templates, upcomingEvents] = await Promise.all([
|
||||
findAllSports(),
|
||||
findAllSportsSeasons(),
|
||||
findAllSeasonTemplates(),
|
||||
getEventsForDates([today, tomorrow]),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
@ -31,11 +45,216 @@ export async function loader() {
|
|||
sportsSeasonsCount: sportsSeasons.length,
|
||||
templatesCount: templates.length,
|
||||
},
|
||||
upcomingEvents,
|
||||
today,
|
||||
tomorrow,
|
||||
};
|
||||
}
|
||||
|
||||
function formatEventTime(eventStartsAt: string | null): string | null {
|
||||
if (!eventStartsAt) return null;
|
||||
const d = new Date(eventStartsAt);
|
||||
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function getEventTypeLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case "playoff_game": return "Bracket";
|
||||
case "major_tournament": return "Tournament";
|
||||
case "final_standings": return "Final Standings";
|
||||
default: return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
type DashboardEvent = {
|
||||
id: string;
|
||||
name: string;
|
||||
eventDate: string | null;
|
||||
eventStartsAt: string | null;
|
||||
eventType: string;
|
||||
isComplete: boolean;
|
||||
completedAt: string | null;
|
||||
sportsSeasonId: string;
|
||||
sportsSeasonName: string;
|
||||
sportName: string;
|
||||
sportSlug: string | null;
|
||||
};
|
||||
|
||||
function GamesToScore({
|
||||
events,
|
||||
today,
|
||||
tomorrow,
|
||||
}: {
|
||||
events: DashboardEvent[];
|
||||
today: string;
|
||||
tomorrow: string;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<"today" | "tomorrow">("today");
|
||||
|
||||
const todayEvents = events.filter((e) => e.eventDate === today);
|
||||
const tomorrowEvents = events.filter((e) => e.eventDate === tomorrow);
|
||||
const displayEvents = activeTab === "today" ? todayEvents : tomorrowEvents;
|
||||
|
||||
const scored = displayEvents.filter((e) => e.isComplete).length;
|
||||
const total = displayEvents.length;
|
||||
const pending = total - scored;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Games to Score</CardTitle>
|
||||
<CardDescription>Scoring events that need your attention</CardDescription>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{scored}/{total}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">scored</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab selector */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => setActiveTab("today")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === "today"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
Today
|
||||
{todayEvents.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}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("tomorrow")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === "tomorrow"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
Tomorrow
|
||||
{tomorrowEvents.length > 0 && (
|
||||
<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs ${
|
||||
activeTab === "tomorrow"
|
||||
? "bg-primary-foreground/20 text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{tomorrowEvents.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{displayEvents.length === 0 ? (
|
||||
<div className="py-6 text-center text-muted-foreground text-sm">
|
||||
No scoring events scheduled for {activeTab === "today" ? "today" : "tomorrow"}.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{pending > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-sm text-amber-800 dark:text-amber-300">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span>{pending} event{pending !== 1 ? "s" : ""} still need{pending === 1 ? "s" : ""} scoring</span>
|
||||
</div>
|
||||
)}
|
||||
{pending === 0 && total > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-md bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-3 py-2 text-sm text-green-800 dark:text-green-300">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
<span>All events scored for {activeTab === "today" ? "today" : "tomorrow"}!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{displayEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors ${
|
||||
event.isComplete
|
||||
? "border-border/50 bg-muted/30 opacity-70"
|
||||
: "border-border bg-card hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium truncate">{event.name}</span>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{getEventTypeLabel(event.eventType)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||||
<span className="font-medium text-foreground/70">{event.sportName}</span>
|
||||
<span>·</span>
|
||||
<span>{event.sportsSeasonName}</span>
|
||||
{event.eventStartsAt && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{formatEventTime(event.eventStartsAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-3 shrink-0">
|
||||
{event.isComplete ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-green-300 text-green-700 dark:border-green-700 dark:text-green-400 bg-green-50 dark:bg-green-950/30"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Scored
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30"
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
|
||||
<Link
|
||||
to={`/admin/sports-seasons/${event.sportsSeasonId}/events/${event.id}`}
|
||||
>
|
||||
{event.isComplete ? "View" : "Score"}
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
|
||||
const { stats } = loaderData;
|
||||
const { stats, upcomingEvents, today, tomorrow } = loaderData;
|
||||
|
||||
// Serialize dates to strings for the client component
|
||||
const serializedEvents: DashboardEvent[] = upcomingEvents.map((e) => ({
|
||||
...e,
|
||||
eventStartsAt: e.eventStartsAt ? new Date(e.eventStartsAt).toISOString() : null,
|
||||
completedAt: e.completedAt ? new Date(e.completedAt).toISOString() : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
|
|
@ -89,6 +308,11 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Games to Score widget — full width */}
|
||||
<div className="mb-6">
|
||||
<GamesToScore events={serializedEvents} today={today} tomorrow={tomorrow} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue