brackt/app/components/scoring/Cs2TournamentBracket.tsx
Claude 999f70c4eb
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

201 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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