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>> { + const db = providedDb || database(); + + const results = await db + .select({ + sportsSeasonId: schema.participants.sportsSeasonId, + participantId: schema.participants.id, + participantName: schema.participants.name, + }) + .from(schema.draftPicks) + .innerJoin( + schema.participants, + eq(schema.draftPicks.participantId, schema.participants.id) + ) + .where( + and( + eq(schema.draftPicks.teamId, teamId), + eq(schema.draftPicks.seasonId, seasonId) + ) + ); + + const map = new Map>(); + for (const row of results) { + if (!map.has(row.sportsSeasonId)) { + map.set(row.sportsSeasonId, []); + } + map.get(row.sportsSeasonId)!.push({ id: row.participantId, name: row.participantName }); + } + return map; +} + export async function deleteAllDraftPicks(seasonId: string) { const db = database(); await db diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index a9ba96b..b666cb2 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -1,6 +1,6 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq, and, desc, asc, gte, lte } from "drizzle-orm"; +import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull } from "drizzle-orm"; import { recalculateAffectedLeagues } from "./scoring-calculator"; import { recalculateParticipantQP } from "./qualifying-points"; @@ -326,6 +326,216 @@ export async function getRecentCompletedEvents( }); } +// Scoring patterns where each participant competes in every event. +// Valid scoringPatternEnum values: playoff_bracket | season_standings | qualifying_points +const ALL_COMPETE_PATTERNS = new Set(["season_standings", "qualifying_points"]); + +export interface UpcomingParticipantEvent { + id: string; + name: string; + eventDate: string | null; + /** + * Earliest scheduledAt timestamp (ISO string) across individual playoff match + * games for this event — bracket sports only. Used when eventDate is null. + */ + earliestGameTime: string | null; + /** + * e.g. "Semifinals Game #1" — derived from playoffMatches.round and + * playoffMatchGames.gameNumber. null when unavailable or redundant with event name. + */ + matchLabel: string | null; + eventType: string; + sportsSeasonId: string; + /** Only the current user's drafted participants involved in this event */ + relevantParticipants: Array<{ id: string; name: string }>; +} + +/** + * Get upcoming scoring events (within the given date window) that involve + * at least one of the given drafted participants. + * + * - Bracket sports (playoff_bracket, ucl_bracket): only events where a + * participant appears in a playoff match. + * - All-compete sports (season_standings, qualifying_points, etc.): every + * upcoming event; all draftedParticipants are attached. + * + * Events without a date or that are already complete are excluded. + */ +export async function getUpcomingEventsForDraftedParticipants( + sportsSeasonId: string, + scoringPattern: string, + draftedParticipants: Array<{ id: string; name: string }>, + dateFrom: Date, + dateTo: Date, + providedDb?: ReturnType +): Promise { + if (draftedParticipants.length === 0) return []; + + const db = providedDb || database(); + const dateFromStr = dateFrom.toISOString().split("T")[0]; + const dateToStr = dateTo.toISOString().split("T")[0]; + const draftedIds = draftedParticipants.map((p) => p.id); + const draftedMap = new Map(draftedParticipants.map((p) => [p.id, p])); + + if (ALL_COMPETE_PATTERNS.has(scoringPattern)) { + const events = await db.query.scoringEvents.findMany({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isComplete, false), + isNotNull(schema.scoringEvents.eventDate), + gte(schema.scoringEvents.eventDate, dateFromStr), + lte(schema.scoringEvents.eventDate, dateToStr) + ), + orderBy: [asc(schema.scoringEvents.eventDate)], + }); + + return events.map((e) => ({ + id: e.id, + name: e.name, + eventDate: e.eventDate, + earliestGameTime: null, + matchLabel: null, + eventType: e.eventType, + sportsSeasonId: e.sportsSeasonId, + relevantParticipants: draftedParticipants, + })); + } + + // Bracket sports: join through playoff_matches → playoff_match_games to find relevant events. + // scoringEvent.eventDate may be null — admins often set dates on individual games + // (playoffMatchGames.scheduledAt) rather than on the event record itself. + // We include an event if EITHER its eventDate OR any of its game scheduledAt timestamps + // falls within the window. + // + // Use start-of-day for the timestamp lower bound so games that already happened + // earlier today are still included. + const dateFromTimestamp = new Date(dateFromStr + "T00:00:00.000Z"); + const dateToTimestamp = new Date(dateToStr + "T23:59:59.999Z"); + + const rows = await db + .selectDistinct({ + id: schema.scoringEvents.id, + name: schema.scoringEvents.name, + eventDate: schema.scoringEvents.eventDate, + eventType: schema.scoringEvents.eventType, + sportsSeasonId: schema.scoringEvents.sportsSeasonId, + participant1Id: schema.playoffMatches.participant1Id, + participant2Id: schema.playoffMatches.participant2Id, + round: schema.playoffMatches.round, + gameNumber: schema.playoffMatchGames.gameNumber, + scheduledAt: schema.playoffMatchGames.scheduledAt, + }) + .from(schema.scoringEvents) + .innerJoin( + schema.playoffMatches, + eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id) + ) + .leftJoin( + schema.playoffMatchGames, + eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id) + ) + .where( + and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isComplete, false), + or( + inArray(schema.playoffMatches.participant1Id, draftedIds), + // participant2Id is nullable UUID — same underlying type as participant1Id; + // the notNull difference only exists at the TypeScript layer + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inArray(schema.playoffMatches.participant2Id as any, draftedIds) + ), + or( + // scoringEvent has an explicit date within the window + and( + isNotNull(schema.scoringEvents.eventDate), + gte(schema.scoringEvents.eventDate, dateFromStr), + lte(schema.scoringEvents.eventDate, dateToStr) + ), + // Or one of the individual games is scheduled within the window + and( + isNotNull(schema.playoffMatchGames.scheduledAt), + gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp), + lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp) + ) + ) + ) + ) + .orderBy(asc(schema.scoringEvents.eventDate)); + + // Group rows by event, collecting relevant participants, earliest game time, and match label. + const eventMap = new Map(); + const participantsByEvent = new Map>(); + // Unique "round|gameNumber" combos per event — used to build matchLabel + const gameKeysByEvent = new Map>(); + // Min scheduledAt per event + const earliestTimeByEvent = new Map(); + + for (const row of rows) { + if (!eventMap.has(row.id)) { + eventMap.set(row.id, { + id: row.id, + name: row.name, + eventDate: row.eventDate, + earliestGameTime: null, + matchLabel: null, + eventType: row.eventType, + sportsSeasonId: row.sportsSeasonId, + relevantParticipants: [], + }); + participantsByEvent.set(row.id, new Map()); + gameKeysByEvent.set(row.id, new Set()); + } + + const pMap = participantsByEvent.get(row.id)!; + if (row.participant1Id && draftedMap.has(row.participant1Id)) { + pMap.set(row.participant1Id, draftedMap.get(row.participant1Id)!); + } + if (row.participant2Id && draftedMap.has(row.participant2Id)) { + pMap.set(row.participant2Id, draftedMap.get(row.participant2Id)!); + } + + if (row.round && row.gameNumber != null) { + gameKeysByEvent.get(row.id)!.add(`${row.round}|${row.gameNumber}`); + } + + if (row.scheduledAt) { + const prev = earliestTimeByEvent.get(row.id); + if (!prev || row.scheduledAt < prev) { + earliestTimeByEvent.set(row.id, row.scheduledAt); + } + } + } + + for (const [eventId, event] of eventMap) { + event.relevantParticipants = Array.from(participantsByEvent.get(eventId)!.values()); + + const earliest = earliestTimeByEvent.get(eventId); + event.earliestGameTime = earliest ? earliest.toISOString() : null; + + // Build matchLabel from game keys + const gameKeys = gameKeysByEvent.get(eventId)!; + if (gameKeys.size === 1) { + const [round, gameNumberStr] = [...gameKeys][0].split("|"); + // When round name duplicates the event name, omit the round to avoid + // "Knockout Stage — Knockout Stage Game #1"; just show "Game #1" instead. + event.matchLabel = + round === event.name + ? `Game #${gameNumberStr}` + : `${round} Game #${gameNumberStr}`; + } else if (gameKeys.size > 1) { + // Multiple games — use just the round if it differs from the event name + const rounds = new Set([...gameKeys].map((k) => k.split("|")[0])); + if (rounds.size === 1) { + const round = [...rounds][0]; + event.matchLabel = round === event.name ? null : round; + } + } + } + + return Array.from(eventMap.values()); +} + /** * Bulk create scoring events for a sports season. * Returns created events in order. diff --git a/app/models/team.ts b/app/models/team.ts index 20c9b12..5f78e0e 100644 --- a/app/models/team.ts +++ b/app/models/team.ts @@ -39,6 +39,19 @@ export async function findTeamsByOwnerId(ownerId: string): Promise { }); } +export async function findTeamByOwnerAndSeason( + ownerId: string, + seasonId: string +): Promise { + const db = database(); + return await db.query.teams.findFirst({ + where: and( + eq(schema.teams.ownerId, ownerId), + eq(schema.teams.seasonId, seasonId) + ), + }); +} + export async function findAvailableTeams(seasonId: string): Promise { const db = database(); return await db.query.teams.findMany({ diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 4674a79..0f555fe 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -2,10 +2,16 @@ import { useEffect } from "react"; import { Link, useSearchParams } from "react-router"; import { toast } from "sonner"; import { getAuth } from "@clerk/react-router/server"; +import { addDays } from "date-fns"; import type { Route } from "./+types/home"; import { findLeaguesWithActiveSeasonsByUserId } from "~/models/league"; -import { findSeasonById } from "~/models/season"; +import { findSeasonById, findCurrentSeasonWithSports } from "~/models/season"; +import { findTeamByOwnerAndSeason } from "~/models/team"; +import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick"; +import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event"; +import type { CalendarPanelEvent } from "~/components/sport-season/UpcomingCalendarPanel"; +import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel"; import { Button } from "~/components/ui/button"; import { Card, @@ -29,33 +35,88 @@ export async function loader(args: Route.LoaderArgs) { const { userId } = await getAuth(args); if (!userId) { - return { leagues: [], isLoggedIn: false }; + return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] }; } // Fetch leagues where user has a team in the current season const leagues = await findLeaguesWithActiveSeasonsByUserId(userId); - // Fetch season details for each league's current season - const leaguesWithSeasons = await Promise.all( + const today = new Date(); + const calendarDateFrom = today; + const calendarDateTo = addDays(today, 30); + + // Fetch season details and calendar events in parallel per league + const leaguesWithData = await Promise.all( leagues.map(async (league) => { - const season = league.currentSeasonId - ? await findSeasonById(league.currentSeasonId) - : null; + const [season, seasonWithSports] = await Promise.all([ + league.currentSeasonId ? findSeasonById(league.currentSeasonId) : null, + findCurrentSeasonWithSports(league.id), + ]); + + // Build calendar events for this league + const calendarEvents: CalendarPanelEvent[] = []; + + if (league.currentSeasonId && seasonWithSports?.seasonSports) { + const myTeam = await findTeamByOwnerAndSeason(userId, league.currentSeasonId); + if (myTeam) { + const participantsBySportsSeason = await getDraftedParticipantsBySportsSeason( + myTeam.id, + league.currentSeasonId + ); + + const perSeasonEvents = await Promise.all( + seasonWithSports.seasonSports.map(async ({ sportsSeason: ss }) => { + const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? []; + if (draftedParticipants.length === 0) return []; + + const events = await getUpcomingEventsForDraftedParticipants( + ss.id, + ss.scoringPattern ?? "", + draftedParticipants, + calendarDateFrom, + calendarDateTo + ); + + return events.map((event) => ({ + ...event, + sportName: ss.sport.name, + sportSeasonName: ss.name, + leagueName: league.name, + leagueId: league.id, + sportsSeasonPageUrl: `/leagues/${league.id}/sports-seasons/${ss.id}`, + })); + }) + ); + + calendarEvents.push(...perSeasonEvents.flat()); + } + } + return { ...league, currentSeason: season, + calendarEvents, }; }) ); + const upcomingCalendarEvents = leaguesWithData + .flatMap((l) => l.calendarEvents) + .sort((a, b) => { + const dateA = a.eventDate ?? (a.earliestGameTime ? a.earliestGameTime.split("T")[0] : ""); + const dateB = b.eventDate ?? (b.earliestGameTime ? b.earliestGameTime.split("T")[0] : ""); + return dateA.localeCompare(dateB); + }); + return { - leagues: leaguesWithSeasons, + leagues: leaguesWithData, isLoggedIn: true, + upcomingCalendarEvents, }; } export default function Home({ loaderData }: Route.ComponentProps) { - const { leagues, isLoggedIn } = loaderData; + const { leagues, isLoggedIn, upcomingCalendarEvents } = loaderData; const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { @@ -89,6 +150,12 @@ export default function Home({ loaderData }: Route.ComponentProps) { + {upcomingCalendarEvents.length > 0 && ( +
+ +
+ )} + {leagues.length === 0 ? ( diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index ca83b44..40bf428 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -1,4 +1,5 @@ import { getAuth } from "@clerk/react-router/server"; +import { addDays } from "date-fns"; import { findLeagueById, findTeamsBySeasonId, @@ -10,7 +11,8 @@ import { } from "~/models"; import { findCurrentSeasonWithSports } from "~/models/season"; import { getSeasonStandings } from "~/models/standings"; -import { getNextScoringEvent } from "~/models/scoring-event"; +import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event"; +import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick"; import type { Route } from "./+types/$leagueId"; export async function loader(args: Route.LoaderArgs) { @@ -112,27 +114,61 @@ export async function loader(args: Route.LoaderArgs) { // Count teams with owners const teamsWithOwners = teams.filter((t) => t.ownerId !== null).length; - // Get sports seasons data including next upcoming event for each + // Get sports seasons data with upcoming participant events for the current user const rawSportsSeasons = seasonWithSports?.seasonSports?.map((ss) => ss.sportsSeason) || []; + const myTeam = teams.find((t) => t.ownerId === userId) ?? null; + const today = new Date(); + const calendarDateFrom = today; + const calendarDateTo = addDays(today, 30); + + const participantsBySportsSeason = myTeam + ? await getDraftedParticipantsBySportsSeason(myTeam.id, season!.id) + : new Map>(); + const sportsSeasons = await Promise.all( rawSportsSeasons.map(async (ss) => { - const nextEvent = - ss.status === "active" ? await getNextScoringEvent(ss.id) : null; + const draftedParticipants = participantsBySportsSeason.get(ss.id) ?? []; + const upcomingParticipantEvents = + draftedParticipants.length > 0 + ? await getUpcomingEventsForDraftedParticipants( + ss.id, + ss.scoringPattern ?? "", + draftedParticipants, + calendarDateFrom, + calendarDateTo + ) + : []; return { id: ss.id, name: ss.name, status: ss.status as "upcoming" | "active" | "completed", scoringPattern: ss.scoringPattern, sport: ss.sport, - nextEvent: nextEvent - ? { name: nextEvent.name, eventDate: nextEvent.eventDate } - : null, + upcomingParticipantEvents, }; }) ); const sportsCount = sportsSeasons.length; + // Flatten all events into a panel-ready list, sorted by date + const upcomingCalendarEvents = sportsSeasons + .flatMap((ss) => + ss.upcomingParticipantEvents.map((e) => ({ + ...e, + sportName: ss.sport.name, + sportSeasonName: ss.name, + sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`, + })) + ) + .sort((a, b) => { + // Prefer eventDate; fall back to the date portion of earliestGameTime for bracket + // events where the admin set times on individual games rather than the event record. + const dateA = a.eventDate ?? (a.earliestGameTime ? a.earliestGameTime.split("T")[0] : ""); + const dateB = b.eventDate ?? (b.earliestGameTime ? b.earliestGameTime.split("T")[0] : ""); + return dateA.localeCompare(dateB); + }); + // Check if draft order is set const isDraftOrderSet = draftSlots.length > 0; @@ -156,5 +192,6 @@ export async function loader(args: Route.LoaderArgs) { sportsSeasons, standings, origin, + upcomingCalendarEvents, }; } diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 3aaa46c..f6c72c4 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -13,6 +13,7 @@ import { CardTitle, } from "~/components/ui/card"; import { SportSeasonCard } from "~/components/sports/SportSeasonCard"; +import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel"; import { getDisplayRank } from "~/lib/standings-display"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { @@ -39,6 +40,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { sportsSeasons, standings, origin, + upcomingCalendarEvents, } = loaderData; const myTeam = teams.find((t) => t.ownerId === currentUserId); @@ -237,7 +239,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { seasonName={sportSeason.name} status={sportSeason.status} scoringPattern={sportSeason.scoringPattern} - nextEvent={sportSeason.nextEvent} + upcomingParticipantEvents={sportSeason.upcomingParticipantEvents} /> ))} @@ -381,6 +383,9 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { {/* Right Column - 1/3 width on desktop */}
+ {upcomingCalendarEvents.length > 0 && ( + + )} League Info