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
201 lines
7.2 KiB
TypeScript
201 lines
7.2 KiB
TypeScript
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>
|
||
);
|
||
}
|