diff --git a/app/components/sport-season/UpcomingCalendarPanel.tsx b/app/components/sport-season/UpcomingCalendarPanel.tsx
new file mode 100644
index 0000000..3a135ee
--- /dev/null
+++ b/app/components/sport-season/UpcomingCalendarPanel.tsx
@@ -0,0 +1,144 @@
+import { format } from "date-fns";
+import { Calendar, Users } from "lucide-react";
+import { Link } from "react-router";
+import { Badge } from "~/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { formatEventDate } from "~/lib/date-utils";
+import type { UpcomingParticipantEvent } from "~/models/scoring-event";
+
+export interface CalendarPanelEvent extends UpcomingParticipantEvent {
+ sportName: string;
+ sportSeasonName: string;
+ /** Present when showing events across multiple leagues (home page) */
+ leagueName?: string;
+ leagueId?: string;
+ sportsSeasonPageUrl?: string;
+}
+
+interface Props {
+ events: CalendarPanelEvent[];
+ /** When true, renders a league name badge on each row */
+ showLeague?: boolean;
+}
+
+function ParticipantList({ participants }: { participants: Array<{ id: string; name: string }> }) {
+ const MAX_SHOWN = 3;
+ const shown = participants.slice(0, MAX_SHOWN);
+ const overflow = participants.length - MAX_SHOWN;
+
+ return (
+
+ {shown.map((p) => (
+
+ {p.name}
+
+ ))}
+ {overflow > 0 && (
+
+ +{overflow} more
+
+ )}
+
+ );
+}
+
+function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) {
+ // Prefer game-level scheduledAt over event-level eventDate for display
+ const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
+ const dateStr = gameDate
+ ? format(gameDate, "MMM d")
+ : formatEventDate(event.eventDate);
+ const participantCount = event.relevantParticipants.length;
+ const isAllCompete = event.eventType !== "playoff_game";
+ const displayName = event.matchLabel
+ ? `${event.name} — ${event.matchLabel}`
+ : event.name;
+
+ const content = (
+
+ {/* Date pill */}
+
+
+ {dateStr ?? "TBD"}
+
+ {gameDate && (
+
+ {gameDate.toLocaleTimeString(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ })}
+
+ )}
+
+
+ {/* Event details */}
+
+
+ {displayName}
+ {showLeague && event.leagueName && (
+
+ {event.leagueName}
+
+ )}
+
+
+ {event.sportName} · {event.sportSeasonName}
+
+
+
+ {isAllCompete && participantCount > 1 ? (
+
+ {participantCount} of your picks
+
+ ) : (
+
+ )}
+
+
+
+ );
+
+ if (event.sportsSeasonPageUrl) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return content;
+}
+
+export function UpcomingCalendarPanel({ events, showLeague = false }: Props) {
+ return (
+
+
+
+
+ Upcoming Events
+
+
+
+ {events.length === 0 ? (
+
+ No upcoming events in the next 30 days.
+
+ ) : (
+
+ {events.map((event) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/app/components/sport-season/__tests__/UpcomingCalendarPanel.test.tsx b/app/components/sport-season/__tests__/UpcomingCalendarPanel.test.tsx
new file mode 100644
index 0000000..756cf18
--- /dev/null
+++ b/app/components/sport-season/__tests__/UpcomingCalendarPanel.test.tsx
@@ -0,0 +1,217 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { BrowserRouter } from "react-router";
+import { UpcomingCalendarPanel } from "../UpcomingCalendarPanel";
+import type { CalendarPanelEvent } from "../UpcomingCalendarPanel";
+
+function renderWithRouter(ui: React.ReactElement) {
+ return render({ui});
+}
+
+function makeEvent(overrides: Partial = {}): CalendarPanelEvent {
+ return {
+ id: overrides.id ?? "event-1",
+ name: overrides.name ?? "UCL Quarterfinals",
+ // Use explicit key check so callers can pass null to test the null-date path
+ eventDate: "eventDate" in overrides ? (overrides.eventDate as string | null) : "2025-04-09",
+ earliestGameTime: "earliestGameTime" in overrides ? (overrides.earliestGameTime as string | null) : null,
+ matchLabel: overrides.matchLabel ?? null,
+ eventType: overrides.eventType ?? "playoff_game",
+ sportsSeasonId: overrides.sportsSeasonId ?? "ss-1",
+ sportName: overrides.sportName ?? "Football",
+ sportSeasonName: overrides.sportSeasonName ?? "UCL 2025",
+ leagueName: overrides.leagueName,
+ leagueId: overrides.leagueId,
+ sportsSeasonPageUrl: overrides.sportsSeasonPageUrl,
+ relevantParticipants: overrides.relevantParticipants ?? [
+ { id: "p1", name: "Man City" },
+ ],
+ };
+}
+
+describe("UpcomingCalendarPanel", () => {
+ describe("empty state", () => {
+ it("shows empty state message when no events", () => {
+ renderWithRouter();
+ expect(screen.getByText(/No upcoming events in the next 30 days/i)).toBeInTheDocument();
+ });
+
+ it("renders the panel title", () => {
+ renderWithRouter();
+ expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
+ });
+ });
+
+ describe("event display", () => {
+ it("shows event name and formatted date", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("UCL QF")).toBeInTheDocument();
+ expect(screen.getByText("Apr 9")).toBeInTheDocument();
+ });
+
+ it("shows sport name and season name", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText(/Football · UCL 2025/)).toBeInTheDocument();
+ });
+
+ it("shows participant badge for bracket events", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("Man City")).toBeInTheDocument();
+ });
+
+ it("shows 'N of your picks' for all-compete events with multiple participants", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("5 of your picks")).toBeInTheDocument();
+ });
+
+ it("renders TBD when both eventDate and earliestGameTime are null", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("TBD")).toBeInTheDocument();
+ });
+
+ it("uses earliestGameTime date when eventDate is null", () => {
+ renderWithRouter(
+
+ );
+ // Should not show TBD
+ expect(screen.queryByText("TBD")).not.toBeInTheDocument();
+ });
+
+ it("shows matchLabel appended to event name when present", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText(/PDC World Championship — Semifinals Game #1/)).toBeInTheDocument();
+ });
+
+ it("shows plain event name when matchLabel is null", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("UCL QF")).toBeInTheDocument();
+ });
+ });
+
+ describe("participant overflow", () => {
+ it("shows first 3 participants and an overflow badge for 4+", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
+ expect(screen.getByText("Beta")).toBeInTheDocument();
+ expect(screen.getByText("Gamma")).toBeInTheDocument();
+ expect(screen.queryByText("Delta")).not.toBeInTheDocument();
+ expect(screen.getByText("+2 more")).toBeInTheDocument();
+ });
+
+ it("shows all participants when exactly 3", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
+ expect(screen.getByText("Gamma")).toBeInTheDocument();
+ expect(screen.queryByText(/\+\d+ more/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe("league display", () => {
+ it("does not show league name when showLeague is false (default)", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.queryByText("My League")).not.toBeInTheDocument();
+ });
+
+ it("shows league name badge when showLeague is true", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("My League")).toBeInTheDocument();
+ });
+
+ it("renders multiple events from different leagues", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("League A")).toBeInTheDocument();
+ expect(screen.getByText("League B")).toBeInTheDocument();
+ });
+ });
+
+ describe("link behaviour", () => {
+ it("wraps event row in a link when sportsSeasonPageUrl is provided", () => {
+ renderWithRouter(
+
+ );
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("href", "/leagues/l1/sports-seasons/ss1");
+ });
+ });
+});
diff --git a/app/components/sports/SportSeasonCard.tsx b/app/components/sports/SportSeasonCard.tsx
index a40d1f9..d2d69b4 100644
--- a/app/components/sports/SportSeasonCard.tsx
+++ b/app/components/sports/SportSeasonCard.tsx
@@ -1,5 +1,5 @@
+import { format } from "date-fns";
import { Link } from "react-router";
-import { format, parseISO } from "date-fns";
import { Badge } from "~/components/ui/badge";
import {
Card,
@@ -8,7 +8,9 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
-import { Trophy, Target, Flag, Calendar } from "lucide-react";
+import { Trophy, Target, Flag, Calendar, Users } from "lucide-react";
+import { formatEventDate } from "~/lib/date-utils";
+import type { UpcomingParticipantEvent } from "~/models/scoring-event";
interface SportSeasonCardProps {
leagueId: string;
@@ -17,16 +19,47 @@ interface SportSeasonCardProps {
seasonName: string;
status: "upcoming" | "active" | "completed";
scoringPattern?: string | null;
- nextEvent?: { name: string; eventDate: string | null } | null;
+ upcomingParticipantEvents?: UpcomingParticipantEvent[];
}
-function formatNextEventDate(dateStr: string | null | undefined): string {
- if (!dateStr) return "";
- try {
- return format(parseISO(dateStr), "MMM d");
- } catch {
- return "";
- }
+const MAX_EVENTS_SHOWN = 3;
+
+function EventLine({ event }: { event: UpcomingParticipantEvent }) {
+ const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
+ const dateStr = gameDate
+ ? format(gameDate, "MMM d")
+ : formatEventDate(event.eventDate);
+ const isAllCompete = event.eventType !== "playoff_game";
+ const count = event.relevantParticipants.length;
+ const displayName = event.matchLabel
+ ? `${event.name} — ${event.matchLabel}`
+ : event.name;
+
+ return (
+
+
+
+ {displayName}
+ {dateStr && (
+
+ {" — "}{dateStr}
+
+ )}
+
+ {isAllCompete && count > 1 ? (
+
+
+ {count}
+
+ ) : (
+ event.relevantParticipants.slice(0, 2).map((p) => (
+
+ {p.name}
+
+ ))
+ )}
+
+ );
}
export function SportSeasonCard({
@@ -36,7 +69,7 @@ export function SportSeasonCard({
seasonName,
status,
scoringPattern,
- nextEvent,
+ upcomingParticipantEvents = [],
}: SportSeasonCardProps) {
const getStatusBadge = () => {
switch (status) {
@@ -81,7 +114,9 @@ export function SportSeasonCard({
const scoringLabel = getScoringPatternLabel();
const isPlayoffType = scoringPattern === "playoff_bracket";
- const nextEventDate = formatNextEventDate(nextEvent?.eventDate);
+
+ const shownEvents = upcomingParticipantEvents.slice(0, MAX_EVENTS_SHOWN);
+ const overflowCount = upcomingParticipantEvents.length - MAX_EVENTS_SHOWN;
return (
@@ -107,16 +142,16 @@ export function SportSeasonCard({
{scoringLabel}
)}
- {nextEvent && (
-
-
-
- Next:
- {nextEvent.name}
- {nextEventDate && (
- — {nextEventDate}
- )}
-
+ {shownEvents.length > 0 && (
+
+ {shownEvents.map((event) => (
+
+ ))}
+ {overflowCount > 0 && (
+
+ +{overflowCount} more event{overflowCount > 1 ? "s" : ""}
+
+ )}
)}
diff --git a/app/components/sports/__tests__/SportSeasonCard.test.tsx b/app/components/sports/__tests__/SportSeasonCard.test.tsx
new file mode 100644
index 0000000..338cc33
--- /dev/null
+++ b/app/components/sports/__tests__/SportSeasonCard.test.tsx
@@ -0,0 +1,215 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { BrowserRouter } from "react-router";
+import { SportSeasonCard } from "../SportSeasonCard";
+import type { UpcomingParticipantEvent } from "~/models/scoring-event";
+
+function renderWithRouter(ui: React.ReactElement) {
+ return render({ui});
+}
+
+function makeEvent(overrides: Partial = {}): UpcomingParticipantEvent {
+ return {
+ id: overrides.id ?? "event-1",
+ name: overrides.name ?? "UCL Quarterfinals",
+ eventDate: overrides.eventDate ?? "2025-04-09",
+ earliestGameTime: "earliestGameTime" in overrides ? (overrides.earliestGameTime as string | null) : null,
+ matchLabel: overrides.matchLabel ?? null,
+ eventType: overrides.eventType ?? "playoff_game",
+ sportsSeasonId: overrides.sportsSeasonId ?? "ss-1",
+ relevantParticipants: overrides.relevantParticipants ?? [
+ { id: "p1", name: "Man City" },
+ ],
+ };
+}
+
+const BASE_PROPS = {
+ leagueId: "league-1",
+ sportSeasonId: "ss-1",
+ sportName: "Football",
+ seasonName: "UCL 2025",
+ status: "active" as const,
+};
+
+describe("SportSeasonCard", () => {
+ describe("base rendering", () => {
+ it("shows sport name and season name", () => {
+ renderWithRouter();
+ expect(screen.getByText("Football")).toBeInTheDocument();
+ expect(screen.getByText("UCL 2025")).toBeInTheDocument();
+ });
+
+ it("links to the sports season page", () => {
+ renderWithRouter();
+ expect(screen.getByRole("link")).toHaveAttribute(
+ "href",
+ "/leagues/league-1/sports-seasons/ss-1"
+ );
+ });
+
+ it("shows Active badge for active status", () => {
+ renderWithRouter();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ });
+
+ it("shows Upcoming badge for upcoming status", () => {
+ renderWithRouter();
+ expect(screen.getByText("Upcoming")).toBeInTheDocument();
+ });
+
+ it("shows Completed badge for completed status", () => {
+ renderWithRouter();
+ expect(screen.getByText("Completed")).toBeInTheDocument();
+ });
+ });
+
+ describe("no upcoming events", () => {
+ it("renders without error when upcomingParticipantEvents is empty", () => {
+ renderWithRouter();
+ expect(screen.getByText("Football")).toBeInTheDocument();
+ });
+
+ it("renders without error when upcomingParticipantEvents is omitted (pre-draft)", () => {
+ renderWithRouter();
+ expect(screen.getByText("Football")).toBeInTheDocument();
+ });
+ });
+
+ describe("bracket event display", () => {
+ it("shows event name and date for a bracket event", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("UCL QF")).toBeInTheDocument();
+ expect(screen.getByText("— Apr 9")).toBeInTheDocument();
+ });
+
+ it("shows participant badge for bracket events with one pick", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("Man City")).toBeInTheDocument();
+ });
+
+ it("shows participant badges for two picks in same bracket event", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("Man City")).toBeInTheDocument();
+ expect(screen.getByText("Arsenal")).toBeInTheDocument();
+ });
+ });
+
+ describe("all-compete event display", () => {
+ it("shows participant count badge for schedule_event with multiple picks", () => {
+ renderWithRouter(
+
+ );
+ // Shows count badge, not individual names
+ expect(screen.getByText("5")).toBeInTheDocument();
+ expect(screen.queryByText("Verstappen")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("event overflow", () => {
+ it("shows up to 3 events without overflow message", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("Event One")).toBeInTheDocument();
+ expect(screen.getByText("Event Three")).toBeInTheDocument();
+ expect(screen.queryByText(/more event/i)).not.toBeInTheDocument();
+ });
+
+ it("shows overflow count when more than 3 events", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("Event One")).toBeInTheDocument();
+ expect(screen.queryByText("Event Four")).not.toBeInTheDocument();
+ expect(screen.getByText("+2 more events")).toBeInTheDocument();
+ });
+
+ it("uses singular 'event' when overflow is 1", () => {
+ renderWithRouter(
+
+ );
+ expect(screen.getByText("+1 more event")).toBeInTheDocument();
+ });
+ });
+
+ describe("scoring pattern label", () => {
+ it("shows Playoff Bracket label", () => {
+ renderWithRouter();
+ expect(screen.getByText("Playoff Bracket")).toBeInTheDocument();
+ });
+
+ it("shows Season Standings label", () => {
+ renderWithRouter();
+ expect(screen.getByText("Season Standings")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/app/lib/date-utils.ts b/app/lib/date-utils.ts
index 5455009..6bc88ef 100644
--- a/app/lib/date-utils.ts
+++ b/app/lib/date-utils.ts
@@ -16,6 +16,21 @@
* clear the field. Always write the result to a separate hidden input, not back to
* the datetime-local input itself.
*/
+import { format, parseISO } from "date-fns";
+
+/**
+ * Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
+ * Returns null when the value is missing or unparseable — callers decide how to handle that case.
+ */
+export function formatEventDate(dateStr: string | null | undefined): string | null {
+ if (!dateStr) return null;
+ try {
+ return format(parseISO(dateStr), "MMM d");
+ } catch {
+ return null;
+ }
+}
+
export function localDateTimeToUtcIso(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts
new file mode 100644
index 0000000..30efb2c
--- /dev/null
+++ b/app/models/__tests__/upcoming-calendar.test.ts
@@ -0,0 +1,415 @@
+/**
+ * Tests for getUpcomingEventsForDraftedParticipants logic.
+ *
+ * We test the exported function indirectly by exercising the pure
+ * business-logic paths (all-compete vs bracket, date filtering,
+ * deduplication, empty inputs) using a vi.mock of the database context
+ * so no real DB is required.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// ── DB mock ────────────────────────────────────────────────────────────────
+// Must be declared before the import that triggers the module graph.
+const mockDb = {
+ query: {
+ scoringEvents: { findMany: vi.fn() },
+ },
+ selectDistinct: vi.fn(),
+};
+
+vi.mock("~/database/context", () => ({
+ database: () => mockDb,
+}));
+
+vi.mock("~/database/schema", () => ({
+ scoringEvents: { sportsSeasonId: "se.sports_season_id", isComplete: "se.is_complete", eventDate: "se.event_date", eventType: "se.event_type" },
+ playoffMatches: { scoringEventId: "pm.scoring_event_id", participant1Id: "pm.participant1_id", participant2Id: "pm.participant2_id", round: "pm.round", matchNumber: "pm.match_number" },
+ playoffMatchGames: { playoffMatchId: "pmg.playoff_match_id", scheduledAt: "pmg.scheduled_at", gameNumber: "pmg.game_number" },
+}));
+
+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 { getUpcomingEventsForDraftedParticipants } from "../scoring-event";
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+const DATE_FROM = new Date("2025-04-01");
+const DATE_TO = new Date("2025-04-30");
+const SPORTS_SEASON_ID = "ss-1";
+
+function makeParticipant(id: string, name: string) {
+ return { id, name };
+}
+
+function makeScoringEvent(overrides: Partial<{
+ id: string; name: string; eventDate: string | null;
+ eventType: string; sportsSeasonId: string; isComplete: boolean;
+}> = {}) {
+ return {
+ id: overrides.id ?? "event-1",
+ name: overrides.name ?? "Test Event",
+ eventDate: overrides.eventDate ?? "2025-04-10",
+ eventType: overrides.eventType ?? "schedule_event",
+ sportsSeasonId: overrides.sportsSeasonId ?? SPORTS_SEASON_ID,
+ isComplete: overrides.isComplete ?? false,
+ };
+}
+
+// ── All-compete patterns ───────────────────────────────────────────────────
+describe("getUpcomingEventsForDraftedParticipants — all-compete sports", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // selectDistinct won't be called for all-compete
+ mockDb.selectDistinct.mockReturnValue({ from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) });
+ });
+
+ it("returns [] when draftedParticipants is empty", async () => {
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "season_standings", [], DATE_FROM, DATE_TO
+ );
+ expect(result).toEqual([]);
+ expect(mockDb.query.scoringEvents.findMany).not.toHaveBeenCalled();
+ });
+
+ it("returns all events with all drafted participants attached", async () => {
+ const participants = [makeParticipant("p1", "Verstappen"), makeParticipant("p2", "Hamilton")];
+ const events = [makeScoringEvent({ id: "e1", name: "Australian GP" })];
+ mockDb.query.scoringEvents.findMany.mockResolvedValue(events);
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe("Australian GP");
+ expect(result[0].relevantParticipants).toEqual(participants);
+ });
+
+ it("works for qualifying_points pattern", async () => {
+ const participants = [makeParticipant("g1", "Scheffler")];
+ mockDb.query.scoringEvents.findMany.mockResolvedValue([makeScoringEvent({ id: "e1", name: "Masters" })]);
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "qualifying_points", participants, DATE_FROM, DATE_TO
+ );
+ expect(result).toHaveLength(1);
+ });
+
+ it("unknown pattern (e.g. golf_qualifying_points) falls through to bracket path, not all-compete", async () => {
+ // golf_qualifying_points is a simulatorType, not a scoringPattern.
+ // Unknown patterns are treated as bracket sports (selectDistinct path).
+ const participants = [makeParticipant("g1", "McIlroy")];
+ const selectChain = { from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), leftJoin: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockResolvedValue([]) };
+ mockDb.selectDistinct.mockReturnValue(selectChain);
+
+ await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "golf_qualifying_points", participants, DATE_FROM, DATE_TO
+ );
+
+ // Should NOT call the all-compete findMany path
+ expect(mockDb.query.scoringEvents.findMany).not.toHaveBeenCalled();
+ // Should call the bracket selectDistinct path
+ expect(mockDb.selectDistinct).toHaveBeenCalled();
+ });
+
+ it("returns multiple events sorted by eventDate", async () => {
+ const participants = [makeParticipant("p1", "Verstappen")];
+ const events = [
+ makeScoringEvent({ id: "e2", name: "Miami GP", eventDate: "2025-04-20" }),
+ makeScoringEvent({ id: "e1", name: "Australian GP", eventDate: "2025-04-06" }),
+ ];
+ // findMany is ordered by asc(eventDate) in the real query; simulate same order
+ mockDb.query.scoringEvents.findMany.mockResolvedValue([events[1], events[0]]);
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
+ );
+ expect(result[0].name).toBe("Australian GP");
+ expect(result[1].name).toBe("Miami GP");
+ });
+});
+
+// ── Bracket patterns ───────────────────────────────────────────────────────
+describe("getUpcomingEventsForDraftedParticipants — bracket sports", () => {
+ function makeSelectChain(rows: unknown[]) {
+ const chain = {
+ from: vi.fn().mockReturnThis(),
+ innerJoin: vi.fn().mockReturnThis(),
+ leftJoin: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ orderBy: vi.fn().mockResolvedValue(rows),
+ };
+ return chain;
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns [] when draftedParticipants is empty", async () => {
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket", [], DATE_FROM, DATE_TO
+ );
+ expect(result).toEqual([]);
+ expect(mockDb.selectDistinct).not.toHaveBeenCalled();
+ });
+
+ it("returns one event with one relevant participant (participant1)", async () => {
+ const rows = [{
+ id: "e1", name: "UCL QF", eventDate: "2025-04-09",
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "man-city", participant2Id: "bayern",
+ round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
+ }];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("man-city", "Man City")],
+ DATE_FROM, DATE_TO
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe("UCL QF");
+ expect(result[0].relevantParticipants).toEqual([{ id: "man-city", name: "Man City" }]);
+ });
+
+ it("returns one event when both participants are drafted (no duplication)", async () => {
+ // Two rows for the same event — the DB query uses selectDistinct on event columns
+ // but we may still get two rows if the WHERE matches on both participants.
+ // The grouping logic must deduplicate the event itself.
+ const rows = [
+ {
+ id: "e1", name: "UCL QF", eventDate: "2025-04-09",
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "man-city", participant2Id: "arsenal",
+ round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
+ },
+ ];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("man-city", "Man City"), makeParticipant("arsenal", "Arsenal")],
+ DATE_FROM, DATE_TO
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].relevantParticipants).toHaveLength(2);
+ const names = result[0].relevantParticipants.map((p) => p.name);
+ expect(names).toContain("Man City");
+ expect(names).toContain("Arsenal");
+ });
+
+ it("returns multiple events when picks are in different matches", async () => {
+ const rows = [
+ {
+ id: "e1", name: "UCL QF", eventDate: "2025-04-09",
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "man-city", participant2Id: "real-madrid",
+ round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
+ },
+ {
+ id: "e1", name: "UCL QF", eventDate: "2025-04-09",
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "arsenal", participant2Id: "psg",
+ round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
+ },
+ ];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("man-city", "Man City"), makeParticipant("arsenal", "Arsenal")],
+ DATE_FROM, DATE_TO
+ );
+
+ // Both matches are in the same event → one result with both picks
+ expect(result).toHaveLength(1);
+ expect(result[0].relevantParticipants).toHaveLength(2);
+ });
+
+ it("only includes drafted participants in relevantParticipants, not opponents", async () => {
+ const rows = [{
+ id: "e1", name: "UCL QF", eventDate: "2025-04-09",
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "man-city", participant2Id: "real-madrid",
+ round: "Quarterfinals", gameNumber: 1, scheduledAt: null,
+ }];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("man-city", "Man City")],
+ DATE_FROM, DATE_TO
+ );
+
+ expect(result[0].relevantParticipants).toEqual([{ id: "man-city", name: "Man City" }]);
+ // Real Madrid (the opponent) should NOT appear
+ expect(result[0].relevantParticipants.find((p) => p.id === "real-madrid")).toBeUndefined();
+ });
+
+ it("works for ucl_bracket pattern", async () => {
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain([{
+ id: "e1", name: "UCL SF", eventDate: "2025-04-29",
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "man-city", participant2Id: "arsenal",
+ round: "Semifinals", gameNumber: 1, scheduledAt: null,
+ }]));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "ucl_bracket",
+ [makeParticipant("man-city", "Man City")],
+ DATE_FROM, DATE_TO
+ );
+ expect(result).toHaveLength(1);
+ });
+
+ it("includes events with null eventDate when they have games scheduled within the window", async () => {
+ // Real-world case: admin sets game times on individual games (scheduledAt)
+ // but leaves scoringEvent.eventDate null — the event must still appear.
+ const rows = [{
+ id: "e1", name: "UCL QF",
+ eventDate: null, // ← no date on the event itself
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "barcelona", participant2Id: "sporting",
+ round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
+ }];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("barcelona", "Barcelona")],
+ DATE_FROM, DATE_TO
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe("UCL QF");
+ expect(result[0].eventDate).toBeNull();
+ expect(result[0].relevantParticipants).toEqual([{ id: "barcelona", name: "Barcelona" }]);
+ });
+
+ it("surfaces earliestGameTime from scheduledAt when eventDate is null", async () => {
+ const gameTime = new Date("2025-04-09T14:00:00.000Z");
+ const rows = [{
+ id: "e1", name: "UCL QF",
+ eventDate: null,
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "barcelona", participant2Id: "sporting",
+ round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime,
+ }];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("barcelona", "Barcelona")],
+ DATE_FROM, DATE_TO
+ );
+
+ expect(result[0].earliestGameTime).toBe(gameTime.toISOString());
+ expect(result[0].eventDate).toBeNull();
+ });
+
+ it("picks the earliest scheduledAt when multiple game rows exist for an event", async () => {
+ const gameTime1 = new Date("2025-04-09T13:00:00.000Z");
+ const gameTime2 = new Date("2025-04-09T15:00:00.000Z");
+ const rows = [
+ {
+ id: "e1", name: "UCL QF", eventDate: null,
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "barcelona", participant2Id: "sporting",
+ round: "Knockout Stage", gameNumber: 1, scheduledAt: gameTime2,
+ },
+ {
+ id: "e1", name: "UCL QF", eventDate: null,
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "barcelona", participant2Id: "sporting",
+ round: "Knockout Stage", gameNumber: 2, scheduledAt: gameTime1,
+ },
+ ];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("barcelona", "Barcelona")],
+ DATE_FROM, DATE_TO
+ );
+
+ // Should use the earlier time
+ expect(result[0].earliestGameTime).toBe(gameTime1.toISOString());
+ });
+
+ it("sets matchLabel to round+game when single game key (non-redundant)", async () => {
+ const rows = [{
+ id: "e1", name: "PDC World Championship", eventDate: "2025-04-09",
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "man-city", participant2Id: "arsenal",
+ round: "Semifinals", gameNumber: 1, scheduledAt: null,
+ }];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("man-city", "Man City")],
+ DATE_FROM, DATE_TO
+ );
+
+ expect(result[0].matchLabel).toBe("Semifinals Game #1");
+ });
+
+ it("omits redundant round prefix when round name matches event name", async () => {
+ // e.g. event named "Knockout Stage", round also "Knockout Stage", single game
+ // → show "Game #1" rather than the redundant "Knockout Stage Game #1"
+ const rows = [{
+ id: "e1", name: "Knockout Stage", eventDate: null,
+ eventType: "playoff_game", sportsSeasonId: SPORTS_SEASON_ID,
+ participant1Id: "barcelona", participant2Id: "sporting",
+ round: "Knockout Stage", gameNumber: 1, scheduledAt: null,
+ }];
+ mockDb.selectDistinct.mockReturnValue(makeSelectChain(rows));
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("barcelona", "Barcelona")],
+ DATE_FROM, DATE_TO
+ );
+
+ expect(result[0].matchLabel).toBe("Game #1");
+ });
+
+ it("all-compete events have null earliestGameTime and matchLabel", async () => {
+ const participants = [makeParticipant("p1", "Verstappen")];
+ mockDb.query.scoringEvents.findMany.mockResolvedValue([
+ makeScoringEvent({ id: "e1", name: "Australian GP" }),
+ ]);
+
+ const result = await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "season_standings", participants, DATE_FROM, DATE_TO
+ );
+
+ expect(result[0].earliestGameTime).toBeNull();
+ expect(result[0].matchLabel).toBeNull();
+ });
+
+ it("uses leftJoin so events without any games are still considered", async () => {
+ const chain = makeSelectChain([]);
+ mockDb.selectDistinct.mockReturnValue(chain);
+
+ await getUpcomingEventsForDraftedParticipants(
+ SPORTS_SEASON_ID, "playoff_bracket",
+ [makeParticipant("man-city", "Man City")],
+ DATE_FROM, DATE_TO
+ );
+
+ expect(chain.leftJoin).toHaveBeenCalled();
+ });
+});
diff --git a/app/models/draft-pick.ts b/app/models/draft-pick.ts
index 9f5ebc8..db07039 100644
--- a/app/models/draft-pick.ts
+++ b/app/models/draft-pick.ts
@@ -64,6 +64,45 @@ export async function isParticipantDrafted(seasonId: string, participantId: stri
return !!pick;
}
+/**
+ * Get a team's drafted participants grouped by sports season.
+ * Returns a map of sportsSeasonId → [{id, name}].
+ */
+export async function getDraftedParticipantsBySportsSeason(
+ teamId: string,
+ seasonId: string,
+ providedDb?: ReturnType
+): Promise