From df70ef83c2b45fc2b5d0858f9d9bd685e042a913 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 16 Jun 2026 00:02:28 +0000
Subject: [PATCH] Add CS2 schedule fix, Champions Stage floor scoring, and
event detail view
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Part 1 — CS2 playoff matches now appear in upcoming schedule:
getUpcomingEventsForDraftedParticipants (all-compete path) now also
picks up events with upcoming playoff_match_games.scheduledAt, so CS2
Champions Stage bracket games set via the admin bracket UI surface on
both /upcoming-events and the sport_season EventSchedule panel.
getUpcomingScoringEvents is updated to sort by earliest bracket game
time when eventDate/eventStartsAt are absent or past.
Part 2 — Auto floor-score CS2 Champions Stage qualifiers:
assignCs2EliminationQP now writes provisional floor event_result rows
(placement=5, QP = tie-split of slots 5–8) for the 8 Champions Stage
advancing teams once all Stage-3 exits are known. The bracket admin's
result entry overwrites these via onConflictDoUpdate.
Part 3 — Public event detail view:
New route /leagues/:leagueId/sports-seasons/:sportsSeasonId/events/:eventId
shows CS2 Swiss stage progress (W-L table + matches per stage) and the
Champions Stage bracket, bracket events (tennis/golf bracket + groups),
and results tables for QP/racing events. EventSchedule and
UpcomingCalendarPanel event rows now link to this detail page.
https://claude.ai/code/session_01RYK7NCjcaah545979wupcM
---
app/components/events/Cs2MajorEventView.tsx | 288 ++++++++++++++++++
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 | 19 +-
app/models/cs2-major-stage.ts | 36 ++-
app/models/scoring-event.ts | 172 +++++++++--
app/routes.ts | 4 +
....$sportsSeasonId.events.$eventId.server.ts | 249 +++++++++++++++
...easons.$sportsSeasonId.events.$eventId.tsx | 178 +++++++++++
...eagueId.sports-seasons.$sportsSeasonId.tsx | 2 +
.../leagues/$leagueId.upcoming-events.tsx | 3 +
app/routes/upcoming-events.tsx | 3 +
13 files changed, 1004 insertions(+), 35 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..9165388
--- /dev/null
+++ b/app/components/events/Cs2MajorEventView.tsx
@@ -0,0 +1,288 @@
+import { useState } from "react";
+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 { 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 StageStatusBadge({ result }: { result: Cs2StageResultWithName }) {
+ if (result.stageEliminated !== null) {
+ return (
+
+
+ Eliminated
+
+ );
+ }
+ const winsNeeded = 3;
+ const wins = result.stageEliminatedWins ?? 0;
+ if (result.stageEliminated === null && result.stageEntry !== null) {
+ // Null stageEliminated = either in progress or advanced
+ // If their stageEntry is less than the current stage context, they advanced
+ return (
+
+
+ Advanced
+
+ );
+ }
+ return (
+
+
+ In Progress
+
+ );
+}
+
+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 teamsInStage = stageResults.filter((r) => r.stageEntry <= stage && (r.stageEliminated === null || r.stageEliminated >= stage));
+ const matchesInStage = matches.filter((m) => m.matchStage === stage);
+
+ // Group teams by W-L record (approximated from stageEliminatedWins if eliminated, else unknown)
+ const eliminated = stageResults.filter((r) => r.stageEliminated === stage);
+ const advanced = stageResults.filter((r) => r.stageEliminated === null && r.stageEntry <= 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 && r.stageEliminated === null ? (
+
+ 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 hasChampions = playoffMatches.length > 0;
+ const [activeTab, setActiveTab] = useState(hasChampions ? "champions" : 3);
+
+ const tabs: StageTab[] = [1, 2, 3, "champions"];
+ const ownershipMap = new Map(teamOwnerships.map((o) => [o.participantId, o]));
+
+ 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" && (
+ hasChampions ? (
+
+ ) : (
+
+
+
+ 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..cb730e2 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).
+describe("Champions Stage floor QP calculation", () => {
+ 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],
+ ]);
+ }
+
+ 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..814f35a 100644
--- a/app/models/__tests__/upcoming-calendar.test.ts
+++ b/app/models/__tests__/upcoming-calendar.test.ts
@@ -32,14 +32,27 @@ 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)
+// The chain is thenable, so `await chain` (or any sub-chain that returns `this`)
+// resolves to `rows`. orderBy also returns a resolved promise for the same rows.
function makeGroupStageSelectChain(rows: unknown[]) {
- return {
+ const chain: Record & {
+ then?: (resolve: (v: unknown[]) => unknown, reject?: (e: unknown) => unknown) => Promise;
+ } = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
- where: vi.fn().mockReturnThis(),
+ leftJoin: vi.fn().mockReturnThis(),
+ where: vi.fn(),
orderBy: vi.fn().mockResolvedValue(rows),
+ then: (resolve: (v: unknown[]) => unknown, reject?: (e: unknown) => unknown) =>
+ Promise.resolve(rows).then(resolve, reject),
};
+ // where returns the chain (which is thenable), so await chain.where() → rows
+ (chain.where as ReturnType).mockReturnValue(chain);
+ 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..18393eb 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,61 @@ export async function getUpcomingScoringEvents(
) {
const db = providedDb || database();
- return db.query.scoringEvents.findMany({
+ // Fetch all incomplete events for the season (typically few per season).
+ 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,
});
+
+ 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].sort((a, b) => getEffectiveMs(a) - getEffectiveMs(b)).slice(0, limit);
}
/**
@@ -341,6 +391,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 +443,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 +460,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 +548,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 +653,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 +782,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..9dbf30d
--- /dev/null
+++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server.ts
@@ -0,0 +1,249 @@
+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 = PlayoffMatch & {
+ 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, eventResultRows] = 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 },
+ }),
+ getEventResults(eventId),
+ ]);
+
+ const playoffMatches = playoffMatchRows as PlayoffMatchWithRelations[];
+ 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,
+ eventResults: eventResultRows.map((r) => ({
+ id: r.id,
+ placement: r.placement,
+ qualifyingPointsAwarded: r.qualifyingPointsAwarded,
+ seasonParticipantId: r.seasonParticipantId,
+ participantName: r.seasonParticipant?.name ?? 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 = playoffMatchRows as PlayoffMatchWithRelations[];
+ 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.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.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..67fa8ca
--- /dev/null
+++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.tsx
@@ -0,0 +1,178 @@
+import { Link } from "react-router";
+import { ArrowLeft, Calendar } from "lucide-react";
+import { format } from "date-fns";
+import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId";
+import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.events.$eventId.server";
+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 };
+
+function formatEventDate(dateStr: string | null | Date | undefined): string {
+ if (!dateStr) return "Date TBD";
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return String(dateStr);
+ return format(d, "MMM d, yyyy");
+}
+
+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 = scoringEvent.eventStartsAt
+ ? formatEventDate(scoringEvent.eventStartsAt)
+ : formatEventDate(scoringEvent.eventDate);
+
+ 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,
}));
})
);