claude/beautiful-hawking-a72ilq (#92)
This commit is contained in:
parent
1f41748261
commit
fe4e1b3f3c
13 changed files with 968 additions and 36 deletions
257
app/components/events/Cs2MajorEventView.tsx
Normal file
257
app/components/events/Cs2MajorEventView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
)}
|
||||
{event.name}
|
||||
{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">
|
||||
{event.name}
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 5–8 (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 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<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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
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<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: 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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -202,6 +202,8 @@ export default function SportSeasonDetail({
|
|||
<EventSchedule
|
||||
upcomingEvents={upcomingEvents}
|
||||
recentEvents={recentEvents}
|
||||
leagueId={league.id}
|
||||
sportsSeasonId={sportsSeason.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue