- Fix "Advanced" badge in SwissStageTab: condition was `isAdvanced && stageEliminated === null`, missing teams eliminated at later stages; now just `isAdvanced` - Default active tab to highest active Swiss stage rather than Champions whenever no playoff games have a winner yet - Remove dead `eventResults` query from the CS2 loader branch (Cs2MajorEventView has no prop for it) - Remove dead `teamsInStage` variable and unused `StageStatusBadge` component - Serialize `createdAt`/`updatedAt` on playoff match rows in both CS2 and bracket loader branches (matches how seasonMatches dates are handled) - Replace inline `formatEventDate` in event detail route with shared `~/lib/date-utils` version - Add defensive DB-level limit to `getUpcomingScoringEvents` (max(limit*5, 50)) to cap unbounded fetch https://claude.ai/code/session_01RYK7NCjcaah545979wupcM
263 lines
9.2 KiB
TypeScript
263 lines
9.2 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 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);
|
||
|
||
// 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 ? (
|
||
<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"];
|
||
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" && (
|
||
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>
|
||
);
|
||
}
|