brackt/app/components/scoring/Cs2TournamentBracket.tsx

202 lines
7.2 KiB
TypeScript
Raw Normal View History

feat: add match sync service, cron job, public tournament display (Phases 2-4) Phase 2 — Match Sync Adapters + Cron Job: - PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection - EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores - syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline - POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern) - External Season ID field added to sports season create/edit admin forms - Sync from API button wired in CS2 setup page (enabled when externalSeasonId set) Phase 3 — Public Tournament & Schedule Display: - MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping - Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match - /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling Phase 4 — Playoff Bracket Auto-Sync: - externalMatchId column added to playoff_matches table (migration 0121) - Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline - autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use All 2365 tests pass; typecheck clean. https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 20:46:30 +00:00
import { useState } from "react";
import type { SeasonMatch, MatchSubGame } from "~/models/season-match";
import { PlayoffBracket, type Match as PlayoffMatch, type TeamOwnership } from "./PlayoffBracket";
type MatchWithRelations = SeasonMatch & {
participant1: { id: string; name: string } | null;
participant2: { id: string; name: string } | null;
winner: { id: string; name: string } | null;
subGames: MatchSubGame[];
};
interface Cs2TournamentBracketProps {
swissMatches: MatchWithRelations[];
playoffMatches: PlayoffMatch[];
playoffRounds: string[];
bracketTemplateId?: string | null;
teamOwnerships?: TeamOwnership[];
userParticipantIds?: string[];
}
const STAGE_NAMES: Record<number, string> = {
1: "Opening Stage",
2: "Challengers Stage",
3: "Legends Stage",
};
const STATUS_BADGE: Record<string, string> = {
scheduled: "bg-muted text-muted-foreground text-xs px-2 py-0.5 rounded",
in_progress: "bg-amber-500/15 text-amber-400 border border-amber-500/30 text-xs px-2 py-0.5 rounded",
complete: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-xs px-2 py-0.5 rounded",
canceled: "bg-destructive/15 text-destructive text-xs px-2 py-0.5 rounded",
postponed: "bg-muted text-muted-foreground text-xs px-2 py-0.5 rounded italic",
};
function MapScores({ subGames, p1Id, p2Id }: { subGames: MatchSubGame[]; p1Id: string | null; p2Id: string | null }) {
if (subGames.length === 0) return null;
return (
<div className="flex gap-1 mt-1 flex-wrap">
{subGames.map((g) => (
<span key={g.id} className="text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5">
{g.gameLabel && <span className="font-medium text-foreground/70 mr-1">{g.gameLabel}</span>}
<span className={g.winnerId === p1Id ? "text-foreground font-semibold" : ""}>{g.participant1Score ?? "-"}</span>
{" "}
<span className={g.winnerId === p2Id ? "text-foreground font-semibold" : ""}>{g.participant2Score ?? "-"}</span>
</span>
))}
</div>
);
}
function MatchCard({ match }: { match: MatchWithRelations }) {
const p1 = match.participant1;
const p2 = match.participant2;
const winnerId = match.winnerId;
const isComplete = match.status === "complete";
return (
<div className="border border-border rounded-lg p-3 bg-card">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isComplete && winnerId !== p1?.id ? "text-muted-foreground line-through decoration-1" : ""}`}>
{p1?.name ?? "TBD"}
{isComplete && (
<span className="ml-2 text-xs tabular-nums">{match.participant1Score ?? ""}</span>
)}
</div>
<div className={`text-sm font-medium truncate mt-1 ${isComplete && winnerId !== p2?.id ? "text-muted-foreground line-through decoration-1" : ""}`}>
{p2?.name ?? "TBD"}
{isComplete && (
<span className="ml-2 text-xs tabular-nums">{match.participant2Score ?? ""}</span>
)}
</div>
<MapScores subGames={match.subGames} p1Id={p1?.id ?? null} p2Id={p2?.id ?? null} />
</div>
<div className="shrink-0">
<span className={STATUS_BADGE[match.status] ?? STATUS_BADGE.scheduled}>
{match.status === "in_progress" && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 mr-1 animate-pulse" />
)}
{match.status === "complete" ? "Final" : match.status === "in_progress" ? "Live" : match.status === "scheduled" ? "Upcoming" : match.status}
</span>
</div>
</div>
</div>
);
}
function SwissStageTab({ stage, matches }: { stage: number; matches: MatchWithRelations[] }) {
if (matches.length === 0) {
return <p className="text-sm text-muted-foreground py-4">No matches for this stage yet.</p>;
}
// Group matches by round
const rounds = new Map<number, MatchWithRelations[]>();
for (const m of matches) {
const r = m.matchRound ?? 0;
if (!rounds.has(r)) rounds.set(r, []);
rounds.get(r)!.push(m);
}
const sortedRounds = [...rounds.entries()].sort(([a], [b]) => a - b);
return (
<div className="space-y-6">
{sortedRounds.map(([round, roundMatches]) => (
<div key={round}>
{round > 0 && (
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
Round {round}
</h4>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{roundMatches.map((m) => (
<MatchCard key={m.id} match={m} />
))}
</div>
</div>
))}
</div>
);
}
export function Cs2TournamentBracket({
swissMatches,
playoffMatches,
playoffRounds,
bracketTemplateId,
teamOwnerships,
userParticipantIds,
}: Cs2TournamentBracketProps) {
const stages = [1, 2, 3].filter((s) => swissMatches.some((m) => m.matchStage === s));
const hasBracket = playoffMatches.length > 0;
type Tab = "swiss-1" | "swiss-2" | "swiss-3" | "bracket";
const availableTabs: Tab[] = [
...(stages.includes(1) ? (["swiss-1"] as Tab[]) : []),
...(stages.includes(2) ? (["swiss-2"] as Tab[]) : []),
...(stages.includes(3) ? (["swiss-3"] as Tab[]) : []),
...(hasBracket ? (["bracket"] as Tab[]) : []),
];
const [activeTab, setActiveTab] = useState<Tab>(availableTabs[0] ?? "swiss-1");
const tabLabel: Record<Tab, string> = {
"swiss-1": "Opening Stage",
"swiss-2": "Challengers Stage",
"swiss-3": "Legends Stage",
bracket: "Champions Stage",
};
if (availableTabs.length === 0) {
return (
<div className="text-center text-muted-foreground py-12 text-sm">
No match data available yet.
</div>
);
}
return (
<div className="space-y-4">
{/* Tab bar */}
<div className="flex gap-1 border-b border-border overflow-x-auto pb-0">
{availableTabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tabLabel[tab]}
</button>
))}
</div>
{/* Tab content */}
<div className="pt-2">
{activeTab === "swiss-1" && (
<SwissStageTab stage={1} matches={swissMatches.filter((m) => m.matchStage === 1)} />
)}
{activeTab === "swiss-2" && (
<SwissStageTab stage={2} matches={swissMatches.filter((m) => m.matchStage === 2)} />
)}
{activeTab === "swiss-3" && (
<SwissStageTab stage={3} matches={swissMatches.filter((m) => m.matchStage === 3)} />
)}
{activeTab === "bracket" && hasBracket && (
<PlayoffBracket
matches={playoffMatches}
rounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds}
/>
)}
</div>
</div>
);
}