claude/beautiful-hawking-a72ilq (#92)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m24s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m38s
🚀 Deploy / 🐳 Build (push) Successful in 1m29s
🚀 Deploy / 🚀 Deploy (push) Successful in 9s

This commit is contained in:
chrisp 2026-06-16 14:11:51 +00:00
parent 1f41748261
commit fe4e1b3f3c
13 changed files with 968 additions and 36 deletions

View file

@ -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<SeasonMatch, "scheduledAt" | "startedAt" | "completedAt"> {
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<StageTab, string> = {
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 (
<p className="text-sm text-muted-foreground py-4">No teams assigned to this stage yet.</p>
);
}
return (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Team</TableHead>
<TableHead>Manager</TableHead>
<TableHead className="text-right">W</TableHead>
<TableHead className="text-right">L</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow
key={r.participantId}
className={cn(
isUser && "bg-electric/5",
isElim && "opacity-50"
)}
>
<TableCell className="font-medium">
{r.participantName}
{isUser && <span className="ml-1.5 text-xs text-electric"></span>}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{ownership ? ownership.teamName : "—"}
</TableCell>
<TableCell className="text-right">{wins}</TableCell>
<TableCell className="text-right">{losses}</TableCell>
<TableCell>
{isElim ? (
<span className="flex items-center gap-1 text-xs text-red-400">
<XCircle className="h-3 w-3" /> Eliminated
</span>
) : isAdvanced ? (
<span className="flex items-center gap-1 text-xs text-emerald-400">
<CheckCircle2 className="h-3 w-3" /> Advanced
</span>
) : (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> In Progress
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{matchesInStage.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Matches</h3>
<div className="space-y-1">
{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 (
<div
key={m.id}
className={cn(
"flex items-center justify-between text-sm px-3 py-1.5 rounded-md",
m.status === "complete" ? "bg-muted/30" : "bg-muted/10"
)}
>
<span className={cn(m.winnerId === m.participant1Id && "font-semibold text-emerald-400")}>{p1Name}</span>
<span className="text-xs text-muted-foreground px-2">
{m.status === "complete"
? `${m.participant1Score ?? 0}${m.participant2Score ?? 0}`
: dateStr ?? "vs"}
</span>
<span className={cn("text-right", m.winnerId === m.participant2Id && "font-semibold text-emerald-400")}>{p2Name}</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
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<StageTab>(
hasChampionsActivity ? "champions" : highestActiveStage
);
const tabs: StageTab[] = [1, 2, 3, "champions"];
return (
<div className="space-y-4">
{/* Tab bar */}
<div className="flex gap-1 rounded-lg bg-muted p-1 w-fit">
{tabs.map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
activeTab === tab
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{STAGE_LABELS[tab]}
</button>
))}
</div>
{/* Swiss stage tabs */}
{activeTab !== "champions" && (
<SwissStageTab
stage={activeTab as 1 | 2 | 3}
stageResults={cs2StageResults.filter(
(r) =>
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 ? (
<PlayoffBracket
matches={playoffMatches}
rounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds}
showOwnership={true}
title="Champions Stage"
mode="bracket"
/>
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">
Champions Stage bracket will be available once Stage 3 is complete.
</p>
</CardContent>
</Card>
)
)}
</div>
);
}

View file

@ -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
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-3">
{upcomingEvents.map((event, index) => (
{upcomingEvents.map((event, index) => {
const eventDetailUrl = leagueId && sportsSeasonId
? `/leagues/${leagueId}/sports-seasons/${sportsSeasonId}/events/${event.id}`
: null;
return (
<li
key={event.id}
className={`flex items-start justify-between gap-2 ${
@ -94,7 +102,13 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
{index === 0 && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric mr-2 mb-0.5" />
)}
{eventDetailUrl ? (
<Link to={eventDetailUrl} className="hover:underline">
{event.name}
</Link>
) : (
event.name
)}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" />
@ -123,7 +137,8 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
</Badge>
)}
</li>
))}
);
})}
</ul>
</CardContent>
</Card>
@ -140,7 +155,11 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-3">
{recentEvents.map((event, index) => (
{recentEvents.map((event, index) => {
const eventDetailUrl = leagueId && sportsSeasonId
? `/leagues/${leagueId}/sports-seasons/${sportsSeasonId}/events/${event.id}`
: null;
return (
<li
key={event.id}
className={`flex items-start justify-between gap-2 ${
@ -149,7 +168,13 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
>
<div className="min-w-0">
<p className="text-sm font-medium text-muted-foreground truncate">
{eventDetailUrl ? (
<Link to={eventDetailUrl} className="hover:underline">
{event.name}
</Link>
) : (
event.name
)}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" />
@ -165,7 +190,8 @@ export function EventSchedule({ upcomingEvents, recentEvents }: EventSchedulePro
</Badge>
)}
</li>
))}
);
})}
</ul>
</CardContent>
</Card>

View file

@ -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
</div>
);
if (event.sportsSeasonPageUrl) {
const linkUrl = event.eventDetailUrl ?? event.sportsSeasonPageUrl;
if (linkUrl) {
return (
<Link to={event.sportsSeasonPageUrl} className="block hover:bg-muted/40 -mx-2 px-2 rounded transition-colors">
<Link to={linkUrl} className="block hover:bg-muted/40 -mx-2 px-2 rounded transition-colors">
{content}
</Link>
);

View file

@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { computeStage3ExitQP } from "../cs2-major-stage";
import { calculateSplitQualifyingPoints } from "../qualifying-points";
function makeStage3QPConfig(): Map<number, number> {
const config = new Map<number, number>();
@ -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 58 (the QF-loser tier that all bracket teams are guaranteed
// to reach at minimum).
function makeChampionsQpConfig(): Map<number, number> {
// 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 58 = 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 34 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<number, number>([
[5, 10], [6, 8], [7, 6], [8, 4],
]);
const floorQP = calculateSplitQualifyingPoints(5, 4, config);
expect(floorQP).toBeCloseTo((10 + 8 + 6 + 4) / 4); // = 7
});
});

View file

@ -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", () => ({

View file

@ -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 2532)
* - Stage 2 exits 0 QP, placement = 17 (group start for slots 1724)
* - Stage 3 exits sub-ranked QP, placement = W-L group start slot (916)
* - 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 58 (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 5th8th 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 58; 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(

View file

@ -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<string, Date>();
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) => ({
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<string, Date>();
const extraEventIds = new Set<string>();
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: e.eventStartsAt ? e.eventStartsAt.toISOString() : null,
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(),

View file

@ -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"

View file

@ -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<string, { teamName: string; teamId: string; ownerName?: string }>();
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<PlayoffMatch, "createdAt" | "updatedAt"> & {
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<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
type GroupStandingData = {
groupName: string;
standings: ReturnType<typeof computeGroupStandings>;
matches: Array<Omit<RawGroupMatch, "scheduledAt"> & { 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<typeof m.participant> } => 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<typeof r.participant> } => 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,
};
}

View file

@ -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 (
<div className="container mx-auto py-8 px-4 max-w-5xl">
{/* Breadcrumb */}
<div className="mb-6 space-y-1">
<Link
to={`/leagues/${leagueId}/sports-seasons/${sportsSeasonId}`}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
{sportsSeason.name}
</Link>
<h1 className="text-2xl font-bold">{scoringEvent.name}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-3.5 w-3.5" />
<span suppressHydrationWarning>{eventDate}</span>
{scoringEvent.isComplete && (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 text-xs">
Complete
</Badge>
)}
</div>
</div>
{/* CS2 Major view */}
{loaderData.kind === "cs2" && (
<Cs2MajorEventView
cs2StageResults={loaderData.cs2StageResults}
seasonMatches={loaderData.seasonMatches}
playoffMatches={loaderData.playoffMatches}
playoffRounds={loaderData.playoffRounds}
bracketTemplateId={loaderData.bracketTemplateId}
teamOwnerships={loaderData.teamOwnerships}
userParticipantIds={loaderData.userParticipantIds}
/>
)}
{/* Bracket event (tennis/golf/soccer) */}
{loaderData.kind === "bracket" && (
<div className="space-y-6">
{loaderData.groupStandings.length > 0 && (
<GroupStageStandings
groups={loaderData.groupStandings}
ownershipMap={ownershipMap}
showEmpty={false}
/>
)}
{loaderData.playoffMatches.length > 0 ? (
<PlayoffBracket
matches={loaderData.playoffMatches}
rounds={loaderData.playoffRounds}
bracketTemplateId={loaderData.bracketTemplateId}
preEliminatedParticipants={loaderData.preEliminatedParticipants}
teamOwnerships={loaderData.teamOwnerships}
userParticipantIds={loaderData.userParticipantIds}
showOwnership={true}
mode="bracket"
/>
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">Bracket not yet available.</p>
</CardContent>
</Card>
)}
</div>
)}
{/* Results table (QP tournaments, racing events) */}
{loaderData.kind === "results" && (
loaderData.eventResults.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Results</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Participant</TableHead>
<TableHead>Manager</TableHead>
<TableHead className="text-right">QP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loaderData.eventResults.map((r) => {
const ownership = ownershipMap[r.seasonParticipantId];
const isUser = loaderData.userParticipantIds.includes(r.seasonParticipantId);
return (
<TableRow key={r.id} className={isUser ? "bg-electric/5" : undefined}>
<TableCell className="text-muted-foreground">{r.placement}</TableCell>
<TableCell className="font-medium">
{r.participantName ?? "—"}
{isUser && <span className="ml-1.5 text-xs text-electric"></span>}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{ownership?.teamName ?? "—"}
</TableCell>
<TableCell className="text-right">
{r.qualifyingPointsAwarded !== null
? parseFloat(r.qualifyingPointsAwarded).toFixed(2)
: r.rawScore !== null
? r.rawScore
: "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground text-sm">Results not yet available for this event.</p>
</CardContent>
</Card>
)
)}
</div>
);
}

View file

@ -202,6 +202,8 @@ export default function SportSeasonDetail({
<EventSchedule
upcomingEvents={upcomingEvents}
recentEvents={recentEvents}
leagueId={league.id}
sportsSeasonId={sportsSeason.id}
/>
</div>
)}

View file

@ -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,
}));
})
);

View file

@ -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,
}));
})
);