From fe4e1b3f3c2d66067a0af958e1b12dfc4cb79c3d Mon Sep 17 00:00:00 2001 From: chrisp Date: Tue, 16 Jun 2026 14:11:51 +0000 Subject: [PATCH] claude/beautiful-hawking-a72ilq (#92) --- app/components/events/Cs2MajorEventView.tsx | 257 ++++++++++++++++++ app/components/sport-season/EventSchedule.tsx | 40 ++- .../sport-season/UpcomingCalendarPanel.tsx | 7 +- app/models/__tests__/cs2-major-stage.test.ts | 38 +++ .../__tests__/upcoming-calendar.test.ts | 17 +- app/models/cs2-major-stage.ts | 36 ++- app/models/scoring-event.ts | 174 ++++++++++-- app/routes.ts | 4 + ....$sportsSeasonId.events.$eventId.server.ts | 251 +++++++++++++++++ ...easons.$sportsSeasonId.events.$eventId.tsx | 172 ++++++++++++ ...eagueId.sports-seasons.$sportsSeasonId.tsx | 2 + .../leagues/$leagueId.upcoming-events.tsx | 3 + app/routes/upcoming-events.tsx | 3 + 13 files changed, 968 insertions(+), 36 deletions(-) create mode 100644 app/components/events/Cs2MajorEventView.tsx create mode 100644 app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts create mode 100644 app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx diff --git a/app/components/events/Cs2MajorEventView.tsx b/app/components/events/Cs2MajorEventView.tsx new file mode 100644 index 0000000..07d203c --- /dev/null +++ b/app/components/events/Cs2MajorEventView.tsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { Card, CardContent } from "~/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { cn } from "~/lib/utils"; +import { PlayoffBracket, type Match } from "~/components/scoring/PlayoffBracket"; +import { CheckCircle2, XCircle, Clock } from "lucide-react"; +import type { Cs2StageResultWithName } from "~/models/cs2-major-stage"; +import type { SeasonMatch } from "~/models/season-match"; + +type StageTab = 1 | 2 | 3 | "champions"; + +interface Ownership { + participantId: string; + teamName: string; + teamId: string; + ownerName?: string; +} + +interface Cs2MatchRow extends Omit { + scheduledAt: string | null; + startedAt: string | null; + completedAt: string | null; +} + +interface PlayoffMatchWithRelations extends Match { + participant1: { id: string; name: string } | null; + participant2: { id: string; name: string } | null; + winner: { id: string; name: string } | null; + loser: { id: string; name: string } | null; +} + +interface Props { + cs2StageResults: Cs2StageResultWithName[]; + seasonMatches: Cs2MatchRow[]; + playoffMatches: PlayoffMatchWithRelations[]; + playoffRounds: string[]; + bracketTemplateId: string | null; + teamOwnerships: Ownership[]; + userParticipantIds: string[]; +} + +const STAGE_LABELS: Record = { + 1: "Stage 1", + 2: "Stage 2", + 3: "Stage 3", + champions: "Champions", +}; + +function SwissStageTab({ + stage, + stageResults, + matches, + teamOwnerships, + userParticipantIds, +}: { + stage: 1 | 2 | 3; + stageResults: Cs2StageResultWithName[]; + matches: Cs2MatchRow[]; + teamOwnerships: Ownership[]; + userParticipantIds: string[]; +}) { + const ownershipMap = new Map(teamOwnerships.map((o) => [o.participantId, o])); + const userIds = new Set(userParticipantIds); + + const matchesInStage = matches.filter((m) => m.matchStage === stage); + + if (stageResults.length === 0) { + return ( +

No teams assigned to this stage yet.

+ ); + } + + return ( +
+ + + + Team + Manager + W + L + Status + + + + {stageResults.map((r) => { + const ownership = ownershipMap.get(r.participantId); + const isUser = userIds.has(r.participantId); + const wins = r.stageEliminated === stage ? (r.stageEliminatedWins ?? "—") : "—"; + const losses = r.stageEliminated === stage ? (3 - (r.stageEliminatedWins ?? 0)) : "—"; + const isElim = r.stageEliminated === stage; + const isAdvanced = r.stageEliminated === null || (r.stageEliminated !== null && r.stageEliminated > stage); + return ( + + + {r.participantName} + {isUser && } + + + {ownership ? ownership.teamName : "—"} + + {wins} + {losses} + + {isElim ? ( + + Eliminated + + ) : isAdvanced ? ( + + Advanced + + ) : ( + + In Progress + + )} + + + ); + })} + +
+ + {matchesInStage.length > 0 && ( +
+

Matches

+
+ {matchesInStage.map((m) => { + const p1Name = m.participant1Id + ? stageResults.find((r) => r.participantId === m.participant1Id)?.participantName ?? m.participant1Id.slice(0, 8) + : "TBD"; + const p2Name = m.participant2Id + ? stageResults.find((r) => r.participantId === m.participant2Id)?.participantName ?? m.participant2Id.slice(0, 8) + : "TBD"; + const dateStr = m.scheduledAt + ? new Date(m.scheduledAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }) + : null; + return ( +
+ {p1Name} + + {m.status === "complete" + ? `${m.participant1Score ?? 0}–${m.participant2Score ?? 0}` + : dateStr ?? "vs"} + + {p2Name} +
+ ); + })} +
+
+ )} +
+ ); +} + +export function Cs2MajorEventView({ + cs2StageResults, + seasonMatches, + playoffMatches, + playoffRounds, + bracketTemplateId, + teamOwnerships, + userParticipantIds, +}: Props) { + const hasChampionsActivity = playoffMatches.some((m) => m.winnerId !== null); + const highestActiveStage: 1 | 2 | 3 = + cs2StageResults.some((r) => r.stageEntry >= 3 || r.stageEliminated === 3) ? 3 : + cs2StageResults.some((r) => r.stageEntry >= 2 || r.stageEliminated === 2) ? 2 : 1; + const [activeTab, setActiveTab] = useState( + hasChampionsActivity ? "champions" : highestActiveStage + ); + + const tabs: StageTab[] = [1, 2, 3, "champions"]; + + return ( +
+ {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Swiss stage tabs */} + {activeTab !== "champions" && ( + + r.stageEntry <= (activeTab as number) && + (r.stageEliminated === null || r.stageEliminated >= (activeTab as number)) + )} + matches={seasonMatches} + teamOwnerships={teamOwnerships} + userParticipantIds={userParticipantIds} + /> + )} + + {/* Champions Stage bracket */} + {activeTab === "champions" && ( + playoffMatches.length > 0 ? ( + + ) : ( + + +

+ Champions Stage bracket will be available once Stage 3 is complete. +

+
+
+ ) + )} +
+ ); +} diff --git a/app/components/sport-season/EventSchedule.tsx b/app/components/sport-season/EventSchedule.tsx index 35f5410..1bf4b50 100644 --- a/app/components/sport-season/EventSchedule.tsx +++ b/app/components/sport-season/EventSchedule.tsx @@ -1,4 +1,5 @@ import { format, parseISO } from "date-fns"; +import { Link } from "react-router"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; import { Calendar, CheckCircle2, Clock } from "lucide-react"; @@ -16,6 +17,9 @@ interface ScheduleEvent { interface EventScheduleProps { upcomingEvents: ScheduleEvent[]; recentEvents: ScheduleEvent[]; + /** When provided, event names link to the event detail page */ + leagueId?: string; + sportsSeasonId?: string; } function formatEventDate(dateStr: string | null | undefined): string { @@ -59,7 +63,7 @@ function getEventTypeLabel(eventType: string): string { } } -export function EventSchedule({ upcomingEvents, recentEvents }: EventScheduleProps) { +export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSeasonId }: EventScheduleProps) { const hasUpcoming = upcomingEvents.length > 0; const hasRecent = recentEvents.length > 0; @@ -78,7 +82,11 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
    - {upcomingEvents.map((event, index) => ( + {upcomingEvents.map((event, index) => { + const eventDetailUrl = leagueId && sportsSeasonId + ? `/leagues/${leagueId}/sports-seasons/${sportsSeasonId}/events/${event.id}` + : null; + return (
  • )} - {event.name} + {eventDetailUrl ? ( + + {event.name} + + ) : ( + event.name + )}

    @@ -123,7 +137,8 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro )}

  • - ))} + ); + })}
@@ -140,7 +155,11 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
    - {recentEvents.map((event, index) => ( + {recentEvents.map((event, index) => { + const eventDetailUrl = leagueId && sportsSeasonId + ? `/leagues/${leagueId}/sports-seasons/${sportsSeasonId}/events/${event.id}` + : null; + return (
  • - {event.name} + {eventDetailUrl ? ( + + {event.name} + + ) : ( + event.name + )}

    @@ -165,7 +190,8 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro )}

  • - ))} + ); + })}
diff --git a/app/components/sport-season/UpcomingCalendarPanel.tsx b/app/components/sport-season/UpcomingCalendarPanel.tsx index 663c32d..8b954f5 100644 --- a/app/components/sport-season/UpcomingCalendarPanel.tsx +++ b/app/components/sport-season/UpcomingCalendarPanel.tsx @@ -20,6 +20,8 @@ export interface CalendarPanelEvent extends UpcomingParticipantEvent { leagueName?: string; leagueId?: string; sportsSeasonPageUrl?: string; + /** Deep link to the event detail page. Preferred over sportsSeasonPageUrl when set. */ + eventDetailUrl?: string; } interface Props { @@ -114,9 +116,10 @@ function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague ); - if (event.sportsSeasonPageUrl) { + const linkUrl = event.eventDetailUrl ?? event.sportsSeasonPageUrl; + if (linkUrl) { return ( - + {content} ); diff --git a/app/models/__tests__/cs2-major-stage.test.ts b/app/models/__tests__/cs2-major-stage.test.ts index e83595c..90a2d04 100644 --- a/app/models/__tests__/cs2-major-stage.test.ts +++ b/app/models/__tests__/cs2-major-stage.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { computeStage3ExitQP } from "../cs2-major-stage"; +import { calculateSplitQualifyingPoints } from "../qualifying-points"; function makeStage3QPConfig(): Map { const config = new Map(); @@ -219,3 +220,40 @@ describe("byStageFullField computation", () => { expect(allIds.length).toBe(results.length); }); }); + +// ─── Champions Stage floor QP ────────────────────────────────────────────── +// When all 8 Stage-3 exits are known, assignCs2EliminationQP writes provisional +// floor scores for the 8 advancing Champions Stage teams. The floor is a tie-split +// of placement slots 5–8 (the QF-loser tier that all bracket teams are guaranteed +// to reach at minimum). +function makeChampionsQpConfig(): Map { + // Default QP: 1→20, 2→14, 3→10, 4→8, 5→5, 6→5, 7→3, 8→3 + return new Map([ + [1, 20], [2, 14], [3, 10], [4, 8], + [5, 5], [6, 5], [7, 3], [8, 3], + ]); +} + +describe("Champions Stage floor QP calculation", () => { + it("floor QP with default config equals tie-split of slots 5–8 = 4", () => { + const config = makeChampionsQpConfig(); + // 4 QF losers fill slots 5,6,7,8: (5+5+3+3)/4 = 4 + const floorQP = calculateSplitQualifyingPoints(5, 4, config); + expect(floorQP).toBeCloseTo(4); + }); + + it("floor QP is strictly less than SF-loser tier (slot 3–4 tie-split)", () => { + const config = makeChampionsQpConfig(); + const floorQP = calculateSplitQualifyingPoints(5, 4, config); // QF losers + const sfLoserQP = calculateSplitQualifyingPoints(3, 2, config); // SF losers + expect(floorQP).toBeLessThan(sfLoserQP); + }); + + it("floor QP with custom config is correctly tie-split over 4 slots", () => { + const config = new Map([ + [5, 10], [6, 8], [7, 6], [8, 4], + ]); + const floorQP = calculateSplitQualifyingPoints(5, 4, config); + expect(floorQP).toBeCloseTo((10 + 8 + 6 + 4) / 4); // = 7 + }); +}); diff --git a/app/models/__tests__/upcoming-calendar.test.ts b/app/models/__tests__/upcoming-calendar.test.ts index 80c1e7d..ffa5587 100644 --- a/app/models/__tests__/upcoming-calendar.test.ts +++ b/app/models/__tests__/upcoming-calendar.test.ts @@ -32,14 +32,23 @@ vi.mock("~/database/schema", () => ({ seasonParticipants: { id: "sp.id" }, })); -// Chain helper for the group stage db.select() query (resolves at orderBy). +// Chain helper for db.select() queries. +// Works for both resolution points: +// - await chain.from().innerJoin().where() (game-time lookup) +// - await chain.from().innerJoin().where().orderBy() (group stage) +// `where()` resolves to a real Promise (so `await` works directly) with an +// `orderBy` method attached for callers that chain further before awaiting. function makeGroupStageSelectChain(rows: unknown[]) { - return { + const whereResult = Object.assign(Promise.resolve(rows), { + orderBy: vi.fn().mockResolvedValue(rows), + }); + const chain = { from: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockResolvedValue(rows), + leftJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnValue(whereResult), }; + return chain; } vi.mock("drizzle-orm", () => ({ diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index 3a9e55c..98fd409 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -15,7 +15,7 @@ import { database } from "~/database/context"; import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema"; import { eq, and, sql } from "drizzle-orm"; -import { getQPConfig, recalculateParticipantQP } from "~/models/qualifying-points"; +import { getQPConfig, recalculateParticipantQP, calculateSplitQualifyingPoints } from "~/models/qualifying-points"; export interface Cs2StageResult { id: string; @@ -289,8 +289,10 @@ export function computeStage3ExitQP( * - Stage 1 exits → 0 QP, placement = 25 (group start for slots 25–32) * - Stage 2 exits → 0 QP, placement = 17 (group start for slots 17–24) * - Stage 3 exits → sub-ranked QP, placement = W-L group start slot (9–16) - * - Champions Stage participants (stageEliminated = null) are not touched here; - * their QP is handled by the bracket admin when Champions Stage results are entered. + * - Champions Stage qualifiers (stageEliminated = null) → when all 8 stage-3 exits + * are known, write provisional floor scores: placement = 5, QP = tie-split of + * slots 5–8 (4 QP with default config). These are overwritten by the bracket admin + * as Champions Stage results are entered. * * Writing placement enables processQualifyingEvent to correctly re-derive QP at * finalization via its own tie-split logic (grouping rows by placement value). @@ -364,6 +366,34 @@ export async function assignCs2EliminationQP( } } + // When all Stage-3 exits are finalised, the 8 Champions Stage qualifiers are + // guaranteed at least 5th–8th place. Write provisional floor scores so their + // QP totals reflect the guaranteed minimum immediately rather than showing 0 + // until the bracket admin enters results. + // + // The bracket admin's result entry uses onConflictDoUpdate on the same + // (scoringEventId, seasonParticipantId) key, so these rows are overwritten + // cleanly as each bracket round is resolved. + if (stage3Exits.length === STAGE3_TOTAL_EXITS) { + const championsStageTeams = allResults.filter((r) => r.stageEliminated === null); + if (championsStageTeams.length > 0) { + // 4 QF losers share slots 5–8; all 8 bracket teams are guaranteed at least that tier. + const BRACKET_FLOOR_PLACEMENT = 5; + const BRACKET_QF_LOSER_COUNT = 4; + const floorQP = calculateSplitQualifyingPoints( + BRACKET_FLOOR_PLACEMENT, + BRACKET_QF_LOSER_COUNT, + qpConfig + ); + for (const r of championsStageTeams) { + resultsByParticipant.set(r.participantId, { + qp: floorQP, + placement: BRACKET_FLOOR_PLACEMENT, + }); + } + } + } + const now = new Date(); await Promise.all( diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index 8e474af..4752663 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -283,8 +283,11 @@ export async function areAllEventsComplete( /** * Get upcoming (not yet complete) scoring events for a sports season, - * ordered by eventDate ascending (soonest first). - * Events without a date are included last. + * ordered by effective start date ascending (soonest first). + * + * Effective date = min(eventDate, eventStartsAt, earliest future bracket-game scheduledAt). + * This ensures CS2 Champions Stage events (which have game times set on + * playoff_match_games but no eventDate on the event record) sort correctly. */ export async function getUpcomingScoringEvents( sportsSeasonId: string, @@ -293,14 +296,63 @@ export async function getUpcomingScoringEvents( ) { const db = providedDb || database(); - return db.query.scoringEvents.findMany({ + // Over-fetch relative to the requested limit so the subsequent game-time + // re-sort has enough candidates without an unbounded query. + const events = await db.query.scoringEvents.findMany({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.isComplete, false) ), orderBy: [asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.eventStartsAt), asc(schema.scoringEvents.createdAt)], - limit, + limit: Math.max(limit * 5, 50), }); + + if (events.length === 0) return []; + + // Find earliest upcoming game time per event from bracket match games. + const eventIds = events.map((e) => e.id); + const now = new Date(); + const gameTimeRows = await db + .select({ + eventId: schema.scoringEvents.id, + scheduledAt: schema.playoffMatchGames.scheduledAt, + }) + .from(schema.scoringEvents) + .innerJoin( + schema.playoffMatches, + eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id) + ) + .innerJoin( + schema.playoffMatchGames, + eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id) + ) + .where( + and( + inArray(schema.scoringEvents.id, eventIds), + isNotNull(schema.playoffMatchGames.scheduledAt), + gte(schema.playoffMatchGames.scheduledAt, now) + ) + ); + + const earliestGameById = new Map(); + for (const row of gameTimeRows) { + if (!row.scheduledAt) continue; + const prev = earliestGameById.get(row.eventId); + if (!prev || row.scheduledAt < prev) { + earliestGameById.set(row.eventId, row.scheduledAt); + } + } + + const getEffectiveMs = (e: (typeof events)[0]): number => { + const candidates: number[] = []; + if (e.eventDate) candidates.push(new Date(e.eventDate + "T00:00:00Z").getTime()); + if (e.eventStartsAt) candidates.push(e.eventStartsAt.getTime()); + const game = earliestGameById.get(e.id); + if (game) candidates.push(game.getTime()); + return candidates.length > 0 ? Math.min(...candidates) : Infinity; + }; + + return events.toSorted((a, b) => getEffectiveMs(a) - getEffectiveMs(b)).slice(0, limit); } /** @@ -341,6 +393,13 @@ const ALL_COMPETE_PATTERNS = new Set(["season_standings", "qualifying_points"]); export interface UpcomingParticipantEvent { id: string; + /** + * The actual scoring event database ID. For all-compete events this equals id; + * for bracket events id is a composite key (eventId|matchId|gameNum) so this + * separate field is needed to build event-detail page links. + * null/undefined for group-stage match entries and legacy callers. + */ + scoringEventId?: string | null; name: string; eventDate: string | null; /** @@ -386,8 +445,13 @@ export async function getUpcomingEventsForDraftedParticipants( const draftedIds = draftedParticipants.map((p) => p.id); const draftedMap = new Map(draftedParticipants.map((p) => [p.id, p])); + // Timestamp bounds used by both all-compete and bracket paths below. + const dateFromTimestampAllCompete = new Date(dateFromStr + "T00:00:00.000Z"); + const dateToTimestampAllCompete = new Date(dateToStr + "T23:59:59.999Z"); + if (ALL_COMPETE_PATTERNS.has(scoringPattern)) { - const events = await db.query.scoringEvents.findMany({ + // Primary: events with eventDate in the window. + const eventsByDate = await db.query.scoringEvents.findMany({ where: and( eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), eq(schema.scoringEvents.isComplete, false), @@ -398,16 +462,87 @@ export async function getUpcomingEventsForDraftedParticipants( orderBy: [asc(schema.scoringEvents.eventDate)], }); - return events.map((e) => ({ - id: e.id, - name: e.name, - eventDate: e.eventDate, - earliestGameTime: e.eventStartsAt ? e.eventStartsAt.toISOString() : null, - matchLabel: null, - eventType: e.eventType, - sportsSeasonId: e.sportsSeasonId, - relevantParticipants: draftedParticipants, - })); + const foundByDateIds = new Set(eventsByDate.map((e) => e.id)); + + // Secondary: events that have bracket game times within the window. + // This catches CS2 Champions Stage bracket events whose eventDate is in + // the past (or unset) but which have upcoming playoff_match_games scheduled. + const gameTimeRows = await db + .select({ + eventId: schema.scoringEvents.id, + scheduledAt: schema.playoffMatchGames.scheduledAt, + }) + .from(schema.scoringEvents) + .innerJoin( + schema.playoffMatches, + eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id) + ) + .innerJoin( + schema.playoffMatchGames, + eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id) + ) + .where( + and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.isComplete, false), + isNotNull(schema.playoffMatchGames.scheduledAt), + gte(schema.playoffMatchGames.scheduledAt, dateFromTimestampAllCompete), + lte(schema.playoffMatchGames.scheduledAt, dateToTimestampAllCompete) + ) + ); + + // Collect earliest game time per event; track which event IDs are new. + const earliestGameTimeById = new Map(); + const extraEventIds = new Set(); + for (const row of gameTimeRows) { + if (!row.scheduledAt) continue; + const prev = earliestGameTimeById.get(row.eventId); + if (!prev || row.scheduledAt < prev) { + earliestGameTimeById.set(row.eventId, row.scheduledAt); + } + if (!foundByDateIds.has(row.eventId)) { + extraEventIds.add(row.eventId); + } + } + + // Fetch full event records for any extras not already in eventsByDate. + let extraEvents: (typeof eventsByDate)[0][] = []; + if (extraEventIds.size > 0) { + extraEvents = await db.query.scoringEvents.findMany({ + where: inArray(schema.scoringEvents.id, [...extraEventIds]), + }); + } + + // Merge and sort by effective start date (eventDate → eventStartsAt → game time). + const allEvents = [...eventsByDate, ...extraEvents]; + const getEffectiveMs = (e: (typeof allEvents)[0]): number => { + const candidates: number[] = []; + if (e.eventDate) candidates.push(new Date(e.eventDate + "T00:00:00Z").getTime()); + if (e.eventStartsAt) candidates.push(e.eventStartsAt.getTime()); + const game = earliestGameTimeById.get(e.id); + if (game) candidates.push(game.getTime()); + return candidates.length > 0 ? Math.min(...candidates) : Infinity; + }; + allEvents.sort((a, b) => getEffectiveMs(a) - getEffectiveMs(b)); + + return allEvents.map((e) => { + const gameTime = earliestGameTimeById.get(e.id); + return { + id: e.id, + scoringEventId: e.id, + name: e.name, + eventDate: e.eventDate, + earliestGameTime: gameTime + ? gameTime.toISOString() + : e.eventStartsAt + ? e.eventStartsAt.toISOString() + : null, + matchLabel: null, + eventType: e.eventType, + sportsSeasonId: e.sportsSeasonId, + relevantParticipants: draftedParticipants, + }; + }); } // Bracket sports: join through playoff_matches → playoff_match_games to find relevant events. @@ -415,11 +550,8 @@ export async function getUpcomingEventsForDraftedParticipants( // (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 dateFromTimestamp = dateFromTimestampAllCompete; + const dateToTimestamp = dateToTimestampAllCompete; const rows = await db .selectDistinct({ @@ -523,6 +655,7 @@ export async function getUpcomingEventsForDraftedParticipants( if (!entryMap.has(entryKey)) { entryMap.set(entryKey, { id: entryKey, + scoringEventId: row.id, name: row.name, eventDate: row.eventDate, earliestGameTime: null, @@ -651,6 +784,7 @@ export async function getUpcomingEventsForDraftedParticipants( } bracketResults.push({ id: `group|${row.matchId}`, + scoringEventId: null, name: row.scoringEventName, eventDate: null, earliestGameTime: row.scheduledAt.toISOString(), diff --git a/app/routes.ts b/app/routes.ts index ad85920..9e90920 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -16,6 +16,10 @@ export default [ "leagues/:leagueId/sports-seasons/:sportsSeasonId", "routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx" ), + route( + "leagues/:leagueId/sports-seasons/:sportsSeasonId/events/:eventId", + "routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx" + ), route( "leagues/:leagueId/draft/:seasonId", "routes/leagues/$leagueId.draft.$seasonId.tsx" diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts new file mode 100644 index 0000000..abcb194 --- /dev/null +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts @@ -0,0 +1,251 @@ +import { auth } from "~/lib/auth.server"; +import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId"; +import { + findLeagueById, + isUserLeagueMember, + isCommissioner, + findTeamsBySeasonId, + getUserDisplayName, +} from "~/models"; +import { getDraftPicks } from "~/models/draft-pick"; +import { getScoringEventById } from "~/models/scoring-event"; +import { getCs2StageResultsForEvent } from "~/models/cs2-major-stage"; +import { findSeasonMatchesByScoringEventId } from "~/models/season-match"; +import { getEventResults } from "~/models/event-result"; +import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates"; +import { findGroupsByEventId } from "~/models/tournament-group"; +import { findMatchesByGroupIds, computeGroupStandings } from "~/models/group-stage-match"; +import type { PlayoffMatch } from "~/models/playoff-match"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and, inArray } from "drizzle-orm"; + +export async function loader({ params, request }: Route.LoaderArgs) { + const session = await auth.api.getSession({ headers: request.headers }); + const userId = session?.user.id ?? null; + const { leagueId, sportsSeasonId, eventId } = params; + + if (!userId) { + throw new Response("You must be logged in to view this page", { status: 401 }); + } + + const league = await findLeagueById(leagueId); + if (!league) throw new Response("League not found", { status: 404 }); + + const [isUserCommissioner, isUserMember] = await Promise.all([ + isCommissioner(leagueId, userId), + isUserLeagueMember(leagueId, userId), + ]); + if (!isUserCommissioner && !isUserMember) { + throw new Response("You do not have access to this league", { status: 403 }); + } + + const db = database(); + + const [scoringEvent, sportsSeason] = await Promise.all([ + getScoringEventById(eventId), + db.query.sportsSeasons.findFirst({ + where: eq(schema.sportsSeasons.id, sportsSeasonId), + with: { sport: true, seasonSports: { with: { season: true } } }, + }), + ]); + + if (!scoringEvent) throw new Response("Event not found", { status: 404 }); + if (!sportsSeason) throw new Response("Sports season not found", { status: 404 }); + if (scoringEvent.sportsSeasonId !== sportsSeasonId) { + throw new Response("Event does not belong to this sports season", { status: 404 }); + } + + const seasonSport = sportsSeason.seasonSports?.find( + (ss) => ss.season.leagueId === leagueId + ); + if (!seasonSport) { + throw new Response("This sports season is not linked to this league", { status: 404 }); + } + const season = seasonSport.season; + + // Build team ownership map + const teams = await findTeamsBySeasonId(season.id); + const draftPicks = await getDraftPicks(season.id); + const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[]; + const ownerUserRows = ownerIds.length > 0 + ? await db.query.users.findMany({ + where: inArray(schema.users.id, ownerIds), + columns: { id: true, username: true, displayName: true }, + }) + : []; + const ownerMap = new Map(ownerUserRows.map((u) => [u.id, getUserDisplayName(u) ?? "Unknown"])); + const ownershipMap = new Map(); + for (const pick of draftPicks) { + const team = teams.find((t) => t.id === pick.teamId); + if (team && pick.participantId) { + ownershipMap.set(pick.participantId, { + teamName: team.name, + teamId: team.id, + ownerName: team.ownerId ? ownerMap.get(team.ownerId) : undefined, + }); + } + } + const teamOwnerships = Array.from(ownershipMap.entries()).map(([participantId, data]) => ({ + participantId, + ...data, + })); + + const userTeams = teams.filter((t) => t.ownerId === userId); + const userTeamIds = new Set(userTeams.map((t) => t.id)); + const userParticipantIds = draftPicks + .filter((p) => p.teamId && userTeamIds.has(p.teamId) && p.participantId) + .map((p) => p.participantId as string); + + const simulatorType = sportsSeason.sport?.simulatorType ?? null; + + type PlayoffMatchWithRelations = Omit & { + createdAt: string; + updatedAt: string; + participant1: { id: string; name: string } | null; + participant2: { id: string; name: string } | null; + winner: { id: string; name: string } | null; + loser: { id: string; name: string } | null; + }; + + type RawGroupMatch = Awaited> extends Map> ? T : never; + type GroupStandingData = { + groupName: string; + standings: ReturnType; + matches: Array & { scheduledAt: string | null }>; + }; + + // CS2 Major — load Swiss stage data + bracket + if (simulatorType === "cs2_major_qualifying_points") { + const [cs2StageResults, seasonMatches, playoffMatchRows] = await Promise.all([ + getCs2StageResultsForEvent(eventId), + findSeasonMatchesByScoringEventId(eventId), + db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, eventId), + with: { participant1: true, participant2: true, winner: true, loser: true }, + }), + ]); + + const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({ + ...m, + createdAt: m.createdAt.toISOString(), + updatedAt: m.updatedAt.toISOString(), + })); + const templateId = scoringEvent.bracketTemplateId ?? undefined; + const template = templateId ? getBracketTemplate(templateId) : undefined; + const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template); + + return { + kind: "cs2" as const, + league, + sportsSeason, + scoringEvent, + teamOwnerships, + userParticipantIds, + cs2StageResults, + seasonMatches: seasonMatches.map((m) => ({ + ...m, + scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null, + startedAt: m.startedAt ? m.startedAt.toISOString() : null, + completedAt: m.completedAt ? m.completedAt.toISOString() : null, + })), + playoffMatches, + playoffRounds, + bracketTemplateId: templateId ?? null, + }; + } + + // Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket) + if (scoringEvent.eventType === "playoff_game") { + const playoffMatchRows = await db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, eventId), + with: { participant1: true, participant2: true, winner: true, loser: true }, + }); + const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({ + ...m, + createdAt: m.createdAt.toISOString(), + updatedAt: m.updatedAt.toISOString(), + })); + const templateId = scoringEvent.bracketTemplateId ?? undefined; + const template = templateId ? getBracketTemplate(templateId) : undefined; + const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template); + + let groupStandings: GroupStandingData[] = []; + if (template?.groupStage) { + const groups = await findGroupsByEventId(eventId); + const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id)); + groupStandings = groups.map((group) => { + const groupMatches = matchesByGroupId.get(group.id) ?? []; + const members = (group.members ?? []) + .filter((m): m is typeof m & { participant: NonNullable } => m.participant !== null) + .map((m) => ({ participantId: m.participant.id, participantName: m.participant.name })); + return { + groupName: group.groupName, + standings: computeGroupStandings( + members, + groupMatches.map((m) => ({ + participant1Id: m.participant1Id, + participant2Id: m.participant2Id, + participant1Score: m.participant1Score, + participant2Score: m.participant2Score, + isComplete: m.isComplete, + })) + ), + matches: groupMatches.map((m) => ({ + ...m, + scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null, + })), + }; + }); + } + + // Season participant results for scoring display + const allResults = await db.query.seasonParticipantResults.findMany({ + where: and( + eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId), + eq(schema.seasonParticipantResults.finalPosition, 0) + ), + with: { participant: true }, + }); + const preEliminatedParticipants = allResults + .filter((r): r is typeof r & { participant: NonNullable } => r.participant !== null) + .map((r) => ({ id: r.participant.id, name: r.participant.name })); + + return { + kind: "bracket" as const, + league, + sportsSeason, + scoringEvent, + teamOwnerships, + userParticipantIds, + playoffMatches, + playoffRounds, + bracketTemplateId: templateId ?? null, + groupStandings, + preEliminatedParticipants, + }; + } + + // QP major tournament / racing / other + const eventResultRows = await getEventResults(eventId); + const eventResults = eventResultRows + .filter((r): r is typeof r & { placement: number } => r.placement !== null && !r.notParticipating) + .map((r) => ({ + id: r.id, + placement: r.placement, + qualifyingPointsAwarded: r.qualifyingPointsAwarded, + rawScore: r.rawScore, + seasonParticipantId: r.seasonParticipantId, + participantName: r.seasonParticipant?.name ?? null, + })); + + return { + kind: "results" as const, + league, + sportsSeason, + scoringEvent, + teamOwnerships, + userParticipantIds, + eventResults, + }; +} diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx new file mode 100644 index 0000000..ce01de8 --- /dev/null +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx @@ -0,0 +1,172 @@ +import { Link } from "react-router"; +import { ArrowLeft, Calendar } from "lucide-react"; +import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId"; +import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server"; +import { formatEventDate } from "~/lib/date-utils"; +import { Badge } from "~/components/ui/badge"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { PlayoffBracket } from "~/components/scoring/PlayoffBracket"; +import { GroupStageStandings } from "~/components/sport-season/GroupStageStandings"; +import { Cs2MajorEventView } from "~/components/events/Cs2MajorEventView"; + +export function meta({ data }: Route.MetaArgs) { + const eventName = data?.scoringEvent?.name ?? "Event"; + const leagueName = data?.league?.name ?? "League"; + return [{ title: `${eventName} — ${leagueName} — Brackt` }]; +} + +export { loader }; + +export default function EventDetailPage({ loaderData }: Route.ComponentProps) { + const { league, sportsSeason, scoringEvent } = loaderData; + const leagueId = league.id; + const sportsSeasonId = sportsSeason.id; + + const ownershipMap = Object.fromEntries( + loaderData.teamOwnerships.map((o) => [ + o.participantId, + { teamName: o.teamName, ownerName: o.ownerName ?? "", teamId: o.teamId }, + ]) + ); + + const eventDate = + formatEventDate(scoringEvent.eventStartsAt as string | null) ?? + formatEventDate(scoringEvent.eventDate) ?? + "Date TBD"; + + return ( +
+ {/* Breadcrumb */} +
+ + + {sportsSeason.name} + +

{scoringEvent.name}

+
+ + {eventDate} + {scoringEvent.isComplete && ( + + Complete + + )} +
+
+ + {/* CS2 Major view */} + {loaderData.kind === "cs2" && ( + + )} + + {/* Bracket event (tennis/golf/soccer) */} + {loaderData.kind === "bracket" && ( +
+ {loaderData.groupStandings.length > 0 && ( + + )} + {loaderData.playoffMatches.length > 0 ? ( + + ) : ( + + +

Bracket not yet available.

+
+
+ )} +
+ )} + + {/* Results table (QP tournaments, racing events) */} + {loaderData.kind === "results" && ( + loaderData.eventResults.length > 0 ? ( + + + Results + + + + + + # + Participant + Manager + QP + + + + {loaderData.eventResults.map((r) => { + const ownership = ownershipMap[r.seasonParticipantId]; + const isUser = loaderData.userParticipantIds.includes(r.seasonParticipantId); + return ( + + {r.placement} + + {r.participantName ?? "—"} + {isUser && } + + + {ownership?.teamName ?? "—"} + + + {r.qualifyingPointsAwarded !== null + ? parseFloat(r.qualifyingPointsAwarded).toFixed(2) + : r.rawScore !== null + ? r.rawScore + : "—"} + + + ); + })} + +
+
+
+ ) : ( + + +

Results not yet available for this event.

+
+
+ ) + )} +
+ ); +} diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index 1e9c0ef..6093b88 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -202,6 +202,8 @@ export default function SportSeasonDetail({ )} diff --git a/app/routes/leagues/$leagueId.upcoming-events.tsx b/app/routes/leagues/$leagueId.upcoming-events.tsx index c954218..da671a3 100644 --- a/app/routes/leagues/$leagueId.upcoming-events.tsx +++ b/app/routes/leagues/$leagueId.upcoming-events.tsx @@ -80,6 +80,9 @@ export async function loader(args: Route.LoaderArgs) { sportName: ss.sport.name, sportSeasonName: ss.name, sportsSeasonPageUrl: `/leagues/${leagueId}/sports-seasons/${ss.id}`, + eventDetailUrl: event.scoringEventId + ? `/leagues/${leagueId}/sports-seasons/${ss.id}/events/${event.scoringEventId}` + : undefined, })); }) ); diff --git a/app/routes/upcoming-events.tsx b/app/routes/upcoming-events.tsx index 64891ef..5dd112f 100644 --- a/app/routes/upcoming-events.tsx +++ b/app/routes/upcoming-events.tsx @@ -68,6 +68,9 @@ export async function loader(args: Route.LoaderArgs) { leagueName: league.name, leagueId: league.id, sportsSeasonPageUrl: `/leagues/${league.id}/sports-seasons/${ss.id}`, + eventDetailUrl: event.scoringEventId + ? `/leagues/${league.id}/sports-seasons/${ss.id}/events/${event.scoringEventId}` + : undefined, })); }) );