Add "Games to Score" widget to admin dashboard (#183)
* 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 * Fix getEventsForDates to include bracket events by game scheduledAt The previous implementation only matched on scoringEvents.eventDate, which is often null for bracket events where individual game times are set on playoffMatchGames.scheduledAt instead. Switched to a two-step query mirroring getUpcomingEventsForDraftedParticipants: 1. selectDistinct event IDs via left joins through playoffMatches → playoffMatchGames, matching where eventDate IN dates OR any game's scheduledAt falls within the date range's timestamp bounds 2. findMany on the matched IDs with sportsSeason + sport relations Updated tests to cover the two-step flow, leftJoin usage, and the scheduledAt timestamp bounds appearing in the where clause. https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5 * Document dual game date storage in CLAUDE.md scoringEvents.eventDate and playoffMatchGames.scheduledAt both hold game dates depending on the event type. Added a dedicated section under Important Patterns explaining when each is used and the two-step selectDistinct + findMany pattern required for correct date queries. https://claude.ai/code/session_014zKK15o5RJfTaodLvnZpF5 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d271cc6792
commit
fa0e798db7
4 changed files with 605 additions and 3 deletions
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -147,6 +147,20 @@ const league = await db.query.leagues.findFirst({...});
|
||||||
- Check `clerkId` for ownership validation
|
- Check `clerkId` for ownership validation
|
||||||
- Admin checks via `isAdmin` boolean on users table
|
- Admin checks via `isAdmin` boolean on users table
|
||||||
|
|
||||||
|
### Scoring Event Dates
|
||||||
|
|
||||||
|
Game/event dates are stored in **two different places** depending on the event type, and any date-based query must account for both:
|
||||||
|
|
||||||
|
1. **`scoringEvents.eventDate`** (`date` column, `YYYY-MM-DD` string) — set directly on the event. Used for non-bracket events (majors, final standings) and sometimes for bracket events when the admin sets a single date for the whole round.
|
||||||
|
|
||||||
|
2. **`playoffMatchGames.scheduledAt`** (`timestamp` column) — set on individual games within a bracket match. Bracket events (e.g. a playoff series) often have `eventDate = null` on the scoring event itself, with each game's exact time stored here instead.
|
||||||
|
|
||||||
|
**Pattern for date-range queries:** Use a two-step approach:
|
||||||
|
1. `selectDistinct` event IDs via left joins through `playoffMatches → playoffMatchGames`, filtering with `eventDate IN [dates] OR (scheduledAt >= dayStart AND scheduledAt <= dayEnd)`
|
||||||
|
2. `findMany` on the matched IDs to load full event data with relations
|
||||||
|
|
||||||
|
See `getEventsForDates` and `getUpcomingEventsForDraftedParticipants` in `app/models/scoring-event.ts` for the reference implementation. All timestamp bounds use UTC (`T00:00:00.000Z` / `T23:59:59.999Z`).
|
||||||
|
|
||||||
### Draft Logic
|
### Draft Logic
|
||||||
|
|
||||||
The draft system implements snake draft:
|
The draft system implements snake draft:
|
||||||
|
|
|
||||||
259
app/models/__tests__/scoring-event-dashboard.test.ts
Normal file
259
app/models/__tests__/scoring-event-dashboard.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
/**
|
||||||
|
* Tests for getEventsForDates — the admin dashboard "Games to Score" query.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ── DB mock ──────────────────────────────────────────────────────────────────
|
||||||
|
// selectDistinct returns a fluent chain; findMany is used in step 2.
|
||||||
|
const mockFindMany = vi.fn();
|
||||||
|
const mockSelectChainBase = {
|
||||||
|
from: vi.fn(),
|
||||||
|
leftJoin: vi.fn(),
|
||||||
|
where: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeSelectChain(rows: { id: string }[]) {
|
||||||
|
const chain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
leftJoin: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(rows),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockSelectDistinct = vi.fn();
|
||||||
|
|
||||||
|
const mockDb = {
|
||||||
|
query: {
|
||||||
|
scoringEvents: { findMany: mockFindMany },
|
||||||
|
},
|
||||||
|
selectDistinct: mockSelectDistinct,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({
|
||||||
|
database: () => mockDb,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/database/schema", () => ({
|
||||||
|
scoringEvents: {
|
||||||
|
id: "se.id",
|
||||||
|
eventDate: "se.event_date",
|
||||||
|
eventType: "se.event_type",
|
||||||
|
eventStartsAt: "se.event_starts_at",
|
||||||
|
createdAt: "se.created_at",
|
||||||
|
},
|
||||||
|
playoffMatches: {
|
||||||
|
scoringEventId: "pm.scoring_event_id",
|
||||||
|
id: "pm.id",
|
||||||
|
},
|
||||||
|
playoffMatchGames: {
|
||||||
|
playoffMatchId: "pmg.playoff_match_id",
|
||||||
|
scheduledAt: "pmg.scheduled_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 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getEventsForDates } from "../scoring-event";
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeEventRow(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(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSelectDistinct.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(mockFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when selectDistinct finds no matching event IDs", async () => {
|
||||||
|
mockSelectDistinct.mockReturnValue(makeSelectChain([]));
|
||||||
|
|
||||||
|
const result = await getEventsForDates(["2026-03-20"]);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(mockFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps database rows to DashboardScoringEvent shape", async () => {
|
||||||
|
mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }]));
|
||||||
|
mockFindMany.mockResolvedValueOnce([
|
||||||
|
makeEventRow({
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
mockSelectDistinct.mockReturnValue(
|
||||||
|
makeSelectChain([{ id: "e1" }, { id: "e2" }])
|
||||||
|
);
|
||||||
|
mockFindMany.mockResolvedValueOnce([
|
||||||
|
makeEventRow({ id: "e1", eventDate: "2026-03-20" }),
|
||||||
|
makeEventRow({ id: "e2", eventDate: "2026-03-21" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e-done" }]));
|
||||||
|
mockFindMany.mockResolvedValueOnce([
|
||||||
|
makeEventRow({
|
||||||
|
id: "e-done",
|
||||||
|
isComplete: true,
|
||||||
|
completedAt: new Date("2026-03-20T18:00:00Z"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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");
|
||||||
|
mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }]));
|
||||||
|
mockFindMany.mockResolvedValueOnce([
|
||||||
|
makeEventRow({ id: "e1", eventStartsAt: starts }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getEventsForDates(["2026-03-20"]);
|
||||||
|
|
||||||
|
expect(result[0].eventStartsAt).toEqual(starts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses two DB calls: selectDistinct then findMany", async () => {
|
||||||
|
mockSelectDistinct.mockReturnValue(makeSelectChain([{ id: "e1" }]));
|
||||||
|
mockFindMany.mockResolvedValueOnce([makeEventRow({ id: "e1" })]);
|
||||||
|
|
||||||
|
await getEventsForDates(["2026-03-20"]);
|
||||||
|
|
||||||
|
expect(mockSelectDistinct).toHaveBeenCalledOnce();
|
||||||
|
expect(mockFindMany).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes event IDs from step 1 into findMany in step 2", async () => {
|
||||||
|
mockSelectDistinct.mockReturnValue(
|
||||||
|
makeSelectChain([{ id: "abc" }, { id: "def" }])
|
||||||
|
);
|
||||||
|
mockFindMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getEventsForDates(["2026-03-20"]);
|
||||||
|
|
||||||
|
const findManyArgs = mockFindMany.mock.calls[0][0];
|
||||||
|
const whereStr = JSON.stringify(findManyArgs.where);
|
||||||
|
expect(whereStr).toContain("abc");
|
||||||
|
expect(whereStr).toContain("def");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses leftJoin so bracket events without playoffMatchGames still qualify via eventDate", async () => {
|
||||||
|
const chain = makeSelectChain([]);
|
||||||
|
mockSelectDistinct.mockReturnValue(chain);
|
||||||
|
|
||||||
|
await getEventsForDates(["2026-03-20"]);
|
||||||
|
|
||||||
|
expect(chain.leftJoin).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes both eventDate and scheduledAt timestamp bounds in the where clause", async () => {
|
||||||
|
const chain = makeSelectChain([]);
|
||||||
|
mockSelectDistinct.mockReturnValue(chain);
|
||||||
|
|
||||||
|
await getEventsForDates(["2026-03-20", "2026-03-21"]);
|
||||||
|
|
||||||
|
// The where clause is the first (and only) arg to chain.where
|
||||||
|
const whereArg = JSON.stringify((chain.where as ReturnType<typeof vi.fn>).mock.calls[0][0]);
|
||||||
|
// eventDate inArray check
|
||||||
|
expect(whereArg).toContain("2026-03-20");
|
||||||
|
expect(whereArg).toContain("2026-03-21");
|
||||||
|
// timestamp bounds for scheduledAt
|
||||||
|
expect(whereArg).toContain("T00:00:00.000Z");
|
||||||
|
expect(whereArg).toContain("T23:59:59.999Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes schedule_event type via the where clause", async () => {
|
||||||
|
const chain = makeSelectChain([]);
|
||||||
|
mockSelectDistinct.mockReturnValue(chain);
|
||||||
|
|
||||||
|
await getEventsForDates(["2026-03-20"]);
|
||||||
|
|
||||||
|
const whereArg = JSON.stringify((chain.where as ReturnType<typeof vi.fn>).mock.calls[0][0]);
|
||||||
|
expect(whereArg).toContain("playoff_game");
|
||||||
|
expect(whereArg).toContain("major_tournament");
|
||||||
|
expect(whereArg).toContain("final_standings");
|
||||||
|
expect(whereArg).not.toContain("schedule_event");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -544,6 +544,111 @@ export async function getUpcomingEventsForDraftedParticipants(
|
||||||
return Array.from(eventMap.values());
|
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.
|
||||||
|
*
|
||||||
|
* An event is included if EITHER its own eventDate falls on one of the given
|
||||||
|
* dates, OR any of its playoffMatchGames has a scheduledAt timestamp on one of
|
||||||
|
* those dates. This ensures bracket events where the admin set dates on
|
||||||
|
* individual games (rather than the event itself) are still surfaced.
|
||||||
|
*/
|
||||||
|
export async function getEventsForDates(
|
||||||
|
dates: string[],
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
): Promise<DashboardScoringEvent[]> {
|
||||||
|
if (dates.length === 0) return [];
|
||||||
|
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
// Build timestamp bounds covering the full span of the requested dates so we
|
||||||
|
// can range-check the playoffMatchGames.scheduledAt timestamp column.
|
||||||
|
const sortedDates = [...dates].sort();
|
||||||
|
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.
|
||||||
|
const rows = await db
|
||||||
|
.selectDistinct({ id: schema.scoringEvents.id })
|
||||||
|
.from(schema.scoringEvents)
|
||||||
|
.leftJoin(
|
||||||
|
schema.playoffMatches,
|
||||||
|
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
schema.playoffMatchGames,
|
||||||
|
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.scoringEvents.eventType, [
|
||||||
|
"playoff_game",
|
||||||
|
"major_tournament",
|
||||||
|
"final_standings",
|
||||||
|
]),
|
||||||
|
or(
|
||||||
|
inArray(schema.scoringEvents.eventDate, dates),
|
||||||
|
and(
|
||||||
|
isNotNull(schema.playoffMatchGames.scheduledAt),
|
||||||
|
gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp),
|
||||||
|
lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const eventIds = rows.map((r) => r.id);
|
||||||
|
|
||||||
|
// Step 2: fetch the matched events with sport season + sport info.
|
||||||
|
const events = await db.query.scoringEvents.findMany({
|
||||||
|
where: inArray(schema.scoringEvents.id, eventIds),
|
||||||
|
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.
|
* Bulk create scoring events for a sports season.
|
||||||
* Returns created events in order.
|
* Returns created events in order.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import type { Route } from "./+types/admin._index";
|
import type { Route } from "./+types/admin._index";
|
||||||
|
|
||||||
import { findAllSports } from "~/models/sport";
|
import { findAllSports } from "~/models/sport";
|
||||||
import { findAllSportsSeasons } from "~/models/sports-season";
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
||||||
import { findAllSeasonTemplates } from "~/models/season-template";
|
import { findAllSeasonTemplates } from "~/models/season-template";
|
||||||
|
import { getEventsForDates } from "~/models/scoring-event";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -12,17 +14,29 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
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 {
|
export function meta(): Route.MetaDescriptors {
|
||||||
return [{ title: "Admin - Brackt" }];
|
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() {
|
export async function loader() {
|
||||||
const [sports, sportsSeasons, templates] = await Promise.all([
|
const { today, tomorrow } = getTodayAndTomorrowDates();
|
||||||
|
|
||||||
|
const [sports, sportsSeasons, templates, upcomingEvents] = await Promise.all([
|
||||||
findAllSports(),
|
findAllSports(),
|
||||||
findAllSportsSeasons(),
|
findAllSportsSeasons(),
|
||||||
findAllSeasonTemplates(),
|
findAllSeasonTemplates(),
|
||||||
|
getEventsForDates([today, tomorrow]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -31,11 +45,216 @@ export async function loader() {
|
||||||
sportsSeasonsCount: sportsSeasons.length,
|
sportsSeasonsCount: sportsSeasons.length,
|
||||||
templatesCount: templates.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) {
|
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 (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
|
@ -89,6 +308,11 @@ export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue