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
288 lines
9.9 KiB
TypeScript
288 lines
9.9 KiB
TypeScript
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<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 StageStatusBadge({ result }: { result: Cs2StageResultWithName }) {
|
||
if (result.stageEliminated !== null) {
|
||
return (
|
||
<span className="flex items-center gap-1 text-xs text-red-400">
|
||
<XCircle className="h-3 w-3" />
|
||
Eliminated
|
||
</span>
|
||
);
|
||
}
|
||
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 (
|
||
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
Advanced
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||
<Clock className="h-3 w-3" />
|
||
In Progress
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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 && r.stageEliminated === null ? (
|
||
<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 hasChampions = playoffMatches.length > 0;
|
||
const [activeTab, setActiveTab] = useState<StageTab>(hasChampions ? "champions" : 3);
|
||
|
||
const tabs: StageTab[] = [1, 2, 3, "champions"];
|
||
const ownershipMap = new Map(teamOwnerships.map((o) => [o.participantId, o]));
|
||
|
||
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" && (
|
||
hasChampions ? (
|
||
<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>
|
||
);
|
||
}
|