claude/great-lovelace-r3bznh #88

Merged
chrisp merged 4 commits from claude/great-lovelace-r3bznh into main 2026-06-12 22:35:36 +00:00
20 changed files with 8197 additions and 13 deletions
Showing only changes of commit 999f70c4eb - Show all commits

View file

@ -0,0 +1,201 @@
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>
);
}

View file

@ -0,0 +1,142 @@
import type { SeasonMatch, MatchSubGame } from "~/models/season-match";
type MatchWithRelations = SeasonMatch & {
participant1: { name: string } | null;
participant2: { name: string } | null;
winner: { name: string } | null;
subGames: MatchSubGame[];
};
interface MatchScheduleProps {
matches: MatchWithRelations[];
title?: string;
}
const STATUS_BADGE: Record<string, string> = {
scheduled: "bg-muted text-muted-foreground",
in_progress: "bg-amber-500/15 text-amber-400 border border-amber-500/30",
complete: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30",
canceled: "bg-destructive/15 text-destructive border border-destructive/30",
postponed: "bg-muted text-muted-foreground",
};
const STATUS_LABEL: Record<string, string> = {
scheduled: "Scheduled",
in_progress: "Live",
complete: "Final",
canceled: "Canceled",
postponed: "Postponed",
};
function formatDate(d: Date | null) {
if (!d) return "TBD";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(new Date(d));
}
function MatchRow({ match }: { match: MatchWithRelations }) {
const p1 = match.participant1?.name ?? "TBD";
const p2 = match.participant2?.name ?? "TBD";
const isComplete = match.status === "complete";
const winnerId = match.winnerId;
return (
<div className="flex items-center justify-between py-3 px-4 border-b border-border last:border-0">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isComplete && winnerId === match.participant1Id ? "text-foreground" : isComplete ? "text-muted-foreground" : "text-foreground"}`}>
{p1}
</div>
<div className={`text-sm font-medium truncate ${isComplete && winnerId === match.participant2Id ? "text-foreground" : isComplete ? "text-muted-foreground" : "text-foreground"}`}>
{p2}
</div>
</div>
{isComplete && (
<div className="text-right tabular-nums shrink-0">
<div className={`text-sm font-bold ${winnerId === match.participant1Id ? "text-foreground" : "text-muted-foreground"}`}>
{match.participant1Score ?? "-"}
</div>
<div className={`text-sm font-bold ${winnerId === match.participant2Id ? "text-foreground" : "text-muted-foreground"}`}>
{match.participant2Score ?? "-"}
</div>
</div>
)}
{match.status === "in_progress" && (
<div className="text-right tabular-nums shrink-0">
<div className="text-sm font-bold text-amber-400">{match.participant1Score ?? "-"}</div>
<div className="text-sm font-bold text-amber-400">{match.participant2Score ?? "-"}</div>
</div>
)}
</div>
{match.matchday != null && (
<div className="text-xs text-muted-foreground mt-1">Matchday {match.matchday}</div>
)}
</div>
<div className="ml-4 flex flex-col items-end gap-1 shrink-0">
<span className={`text-xs px-2 py-0.5 rounded-full ${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" />}
{STATUS_LABEL[match.status] ?? match.status}
</span>
<span className="text-xs text-muted-foreground">{formatDate(match.scheduledAt)}</span>
</div>
</div>
);
}
function groupByMatchday(matches: MatchWithRelations[]) {
const groups = new Map<string, MatchWithRelations[]>();
for (const m of matches) {
const key = m.matchday != null ? `Matchday ${m.matchday}` : formatDate(m.scheduledAt)?.split(",")[0] ?? "TBD";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(m);
}
return groups;
}
export function MatchSchedule({ matches, title = "Schedule" }: MatchScheduleProps) {
if (matches.length === 0) {
return (
<div className="rounded-lg border border-border p-6 text-center text-muted-foreground text-sm">
No matches scheduled yet.
</div>
);
}
const hasMatchdays = matches.some((m) => m.matchday != null);
const grouped = hasMatchdays ? groupByMatchday(matches) : null;
if (grouped) {
return (
<div className="space-y-4">
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{[...grouped.entries()].map(([label, group]) => (
<div key={label} className="rounded-lg border border-border overflow-hidden">
<div className="px-4 py-2 bg-muted/30 text-sm font-medium text-muted-foreground">
{label}
</div>
{group.map((m) => (
<MatchRow key={m.id} match={m} />
))}
</div>
))}
</div>
);
}
return (
<div className="space-y-4">
{title && <h3 className="text-lg font-semibold">{title}</h3>}
<div className="rounded-lg border border-border overflow-hidden">
{matches.map((m) => (
<MatchRow key={m.id} match={m} />
))}
</div>
</div>
);
}

View file

@ -11,7 +11,7 @@ import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { doesLoserAdvance } from "~/models/playoff-match";
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user";
import { findDiscordIdsByUserIds } from "~/models/account";
import { createDailySnapshot } from "~/models/standings";
@ -1657,3 +1657,23 @@ export async function recalculateAffectedLeagues(
`[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}`
);
}
export async function autoCompleteRoundIfDone(
eventId: string,
round: string,
sportsSeasonId: string,
db: ReturnType<typeof database>
): Promise<void> {
const allMatches = await findPlayoffMatchesByEventId(eventId);
const roundMatches = allMatches.filter((m) => m.round === round);
if (roundMatches.length === 0) return;
if (!roundMatches.every((m) => m.isComplete)) return;
await db
.update(schema.scoringEvents)
.set({ playoffRound: round, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, eventId));
await processPlayoffEvent(eventId, db, { skipRecalculate: true, skipProbabilities: true });
logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`);
}

View file

@ -70,6 +70,10 @@ export default [
route("how-to-play", "routes/how-to-play.tsx"),
route("rules", "routes/rules.tsx"),
route("sports", "routes/sports.tsx"),
route(
"sports-seasons/:sportsSeasonId/tournament",
"routes/sports-seasons.$sportsSeasonId.tournament.tsx"
),
route("support", "routes/support.tsx"),
route("upcoming-events", "routes/upcoming-events.tsx"),
route("privacy-policy", "routes/privacy-policy.tsx"),
@ -79,6 +83,7 @@ export default [
// Cron job endpoints
route("admin/jobs/run-daily-snapshots", "routes/admin/jobs.run-daily-snapshots.ts"),
route("admin/jobs/sync-and-simulate", "routes/admin/jobs.sync-and-simulate.ts"),
route("admin/jobs/sync-matches", "routes/admin/jobs.sync-matches.ts"),
// Admin routes
route("admin", "routes/admin.tsx", [

View file

@ -1,4 +1,4 @@
import { Form, useLoaderData, useActionData, useNavigation, Link } from 'react-router';
import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
import { findSportsSeasonById } from '~/models/sports-season';
@ -134,6 +134,8 @@ export default function AdminCs2Setup() {
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
const syncFetcher = useFetcher();
const isSyncing = syncFetcher.state !== 'idle';
// Stage assignment state (local, submitted via form)
const [stageSelections, setStageSelections] = useState<Record<string, string>>(() => {
@ -334,16 +336,23 @@ export default function AdminCs2Setup() {
<CardHeader>
<CardTitle className="flex items-center justify-between">
Swiss Rounds
<Form method="post">
<input type="hidden" name="intent" value="sync-matches" />
<Button type="submit" variant="outline" size="sm" disabled>
<RefreshCw className="h-4 w-4 mr-2" />
Sync from API
<syncFetcher.Form method="post" action="/admin/jobs/sync-matches">
<Button
type="submit"
variant="outline"
size="sm"
disabled={isSyncing || !sportsSeason.externalSeasonId}
title={!sportsSeason.externalSeasonId ? "Set External Season ID on the sports season to enable sync" : undefined}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} />
{isSyncing ? 'Syncing...' : 'Sync from API'}
</Button>
</Form>
</syncFetcher.Form>
</CardTitle>
<CardDescription>
Match-level results imported via API (Phase 2). "Sync from API" will be enabled once a PandaScore tournament ID is configured.
{sportsSeason.externalSeasonId
? `Syncing from external ID: ${sportsSeason.externalSeasonId}`
: 'Set an External Season ID on the sports season to enable API sync.'}
</CardDescription>
</CardHeader>
<CardContent>

View file

@ -315,6 +315,7 @@ export async function action(args: Route.ActionArgs) {
const totalMajors = formData.get("totalMajors");
const draftOn = formData.get("draftOn");
const draftOff = formData.get("draftOff");
const externalSeasonId = formData.get("externalSeasonId");
// Validation
if (typeof name !== "string" || !name.trim()) {
@ -378,6 +379,10 @@ export async function action(args: Route.ActionArgs) {
}
}
updateData.externalSeasonId = typeof externalSeasonId === "string" && externalSeasonId.trim()
? externalSeasonId.trim()
: null;
await updateSportsSeason(params.id, updateData);
return { success: true, message: "Sports season updated successfully!" };
@ -566,6 +571,20 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
This season appears in league creation and pre-draft settings only between these two dates (inclusive).
</p>
<div className="space-y-2">
<Label htmlFor="externalSeasonId">External Season ID (Optional)</Label>
<Input
id="externalSeasonId"
name="externalSeasonId"
type="text"
defaultValue={sportsSeason.externalSeasonId || ""}
placeholder="e.g., PandaScore serie_id or ESPN season year"
/>
<p className="text-sm text-muted-foreground">
Used by the match sync cron job. For CS2: PandaScore serie_id. For MLB/NBA: year (e.g., 2025).
</p>
</div>
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}

View file

@ -40,6 +40,7 @@ export async function action({ request }: Route.ActionArgs) {
const totalMajors = formData.get("totalMajors");
const draftOn = formData.get("draftOn");
const draftOff = formData.get("draftOff");
const externalSeasonId = formData.get("externalSeasonId");
// Validation
if (typeof sportId !== "string" || !sportId) {
@ -108,6 +109,10 @@ export async function action({ request }: Route.ActionArgs) {
}
}
if (typeof externalSeasonId === "string" && externalSeasonId.trim()) {
seasonData.externalSeasonId = externalSeasonId.trim();
}
await createSportsSeason(seasonData as NewSportsSeason);
return redirect("/admin/sports-seasons");
@ -261,6 +266,19 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon
</div>
)}
<div className="space-y-2">
<Label htmlFor="externalSeasonId">External Season ID (Optional)</Label>
<Input
id="externalSeasonId"
name="externalSeasonId"
type="text"
placeholder="e.g., PandaScore serie_id or ESPN season year"
/>
<p className="text-sm text-muted-foreground">
Used by the match sync cron job. For CS2: PandaScore serie_id. For MLB/NBA: year (e.g., 2025).
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="draftOn">Draft Open Date</Label>

View file

@ -0,0 +1,38 @@
import { isNotNull } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { requireCronSecret } from "~/lib/cron-auth";
import { syncMatches } from "~/services/match-sync";
export async function action({ request }: { request: Request }) {
requireCronSecret(request);
const db = database();
const seasons = await db.query.sportsSeasons.findMany({
where: isNotNull(schema.sportsSeasons.externalSeasonId),
with: { sport: true },
});
const synced: string[] = [];
const errors: { id: string; name: string; error: string }[] = [];
for (const season of seasons) {
try {
const result = await syncMatches(season.id);
synced.push(season.id);
if (result.errors.length > 0) {
for (const e of result.errors) {
errors.push({ id: season.id, name: season.name, error: e.error });
}
}
} catch (err) {
errors.push({
id: season.id,
name: season.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
return Response.json({ synced, errors }, { status: errors.length > 0 && synced.length === 0 ? 500 : 200 });
}

View file

@ -0,0 +1,137 @@
import { useEffect } from "react";
import { useRevalidator } from "react-router";
import { eq, inArray } from "drizzle-orm";
import type { Route } from "./+types/sports-seasons.$sportsSeasonId.tournament";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { type PlayoffMatch } from "~/models/playoff-match";
import { findSeasonMatchesBySportsSeasonId } from "~/models/season-match";
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
import { Cs2TournamentBracket } from "~/components/scoring/Cs2TournamentBracket";
import { MatchSchedule } from "~/components/scoring/MatchSchedule";
import { ArrowLeft } from "lucide-react";
import { Link } from "react-router";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.sportsSeason?.name ?? "Tournament"} — Schedule & Results` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const { sportsSeasonId } = params;
const db = database();
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, sportsSeasonId),
with: { sport: true },
});
if (!sportsSeason) throw new Response("Sports season not found", { status: 404 });
const simulatorType = sportsSeason.sport?.simulatorType ?? null;
const seasonMatches = await findSeasonMatchesBySportsSeasonId(sportsSeasonId);
// Load playoff matches for bracket sports
let playoffMatches: (PlayoffMatch & { participant1: { id: string; name: string } | null; participant2: { id: string; name: string } | null; winner: { id: string; name: string } | null; loser: { id: string; name: string } | null; })[] = [];
let playoffRounds: string[] = [];
let bracketTemplateId: string | null = null;
if (simulatorType?.endsWith("_bracket") || simulatorType === "cs2_major_qualifying_points") {
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
if (events.length > 0) {
const eventIds = events.map((e) => e.id);
const matches = await db.query.playoffMatches.findMany({
where: (pm) => inArray(pm.scoringEventId, eventIds),
with: {
participant1: true,
participant2: true,
winner: true,
loser: true,
},
});
playoffMatches = matches as typeof playoffMatches;
const templateId = events.find((e) => e.bracketTemplateId)?.bracketTemplateId ?? undefined;
bracketTemplateId = templateId ?? null;
const template = templateId ? getBracketTemplate(templateId) : undefined;
playoffRounds = getOrderedRoundsFromMatches(matches, template);
}
}
const hasLiveMatches = seasonMatches.some((m) => m.status === "in_progress")
|| playoffMatches.some((m) => !m.isComplete);
return {
sportsSeason,
simulatorType,
seasonMatches,
playoffMatches,
playoffRounds,
bracketTemplateId,
hasLiveMatches,
};
}
type LoaderData = Awaited<ReturnType<typeof loader>>;
export default function TournamentPage({ loaderData }: Route.ComponentProps) {
const {
sportsSeason,
simulatorType,
seasonMatches,
playoffMatches,
playoffRounds,
bracketTemplateId,
hasLiveMatches,
} = loaderData;
const { revalidate } = useRevalidator();
// Poll every 30 seconds when any match is live
useEffect(() => {
if (!hasLiveMatches) return;
const interval = setInterval(() => revalidate(), 30_000);
return () => clearInterval(interval);
}, [hasLiveMatches, revalidate]);
const isCs2 = simulatorType === "cs2_major_qualifying_points";
return (
<div className="container mx-auto px-4 py-8 max-w-6xl">
<div className="mb-6">
<Link
to="/"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<h1 className="text-3xl font-bold">{sportsSeason.name}</h1>
{sportsSeason.sport && (
<p className="text-muted-foreground mt-1">{sportsSeason.sport.name}</p>
)}
{hasLiveMatches && (
<div className="mt-2 flex items-center gap-2 text-sm text-amber-400">
<span className="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
Live updating every 30 seconds
</div>
)}
</div>
{isCs2 ? (
<Cs2TournamentBracket
swissMatches={seasonMatches as Parameters<typeof Cs2TournamentBracket>[0]["swissMatches"]}
playoffMatches={playoffMatches as Parameters<typeof Cs2TournamentBracket>[0]["playoffMatches"]}
playoffRounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
/>
) : (
<MatchSchedule matches={seasonMatches as Parameters<typeof MatchSchedule>[0]["matches"]} />
)}
</div>
);
}

View file

@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EspnScheduleAdapter } from "../espn-schedule";
const SAMPLE_RESPONSE = {
events: [
{
id: "401234567",
date: "2025-04-01T18:00:00Z",
competitions: [
{
competitors: [
{
id: "9",
team: { id: "9", displayName: "Boston Red Sox" },
score: "5",
homeAway: "home" as const,
winner: true,
},
{
id: "4",
team: { id: "4", displayName: "New York Yankees" },
score: "3",
homeAway: "away" as const,
winner: false,
},
],
status: { type: { name: "STATUS_FINAL", completed: true } },
matchday: null,
},
],
},
{
id: "401234568",
date: "2025-04-02T19:00:00Z",
competitions: [
{
competitors: [
{
id: "12",
team: { id: "12", displayName: "Los Angeles Dodgers" },
score: "0",
homeAway: "home" as const,
winner: false,
},
{
id: "15",
team: { id: "15", displayName: "San Francisco Giants" },
score: "0",
homeAway: "away" as const,
winner: false,
},
],
status: { type: { name: "STATUS_SCHEDULED", completed: false } },
matchday: null,
},
],
},
],
};
describe("EspnScheduleAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("maps completed matches with scores and winner", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_RESPONSE,
} as Response);
const adapter = new EspnScheduleAdapter("baseball/mlb");
const matches = await adapter.fetchMatches("2025");
expect(matches).toHaveLength(2);
const completed = matches[0];
expect(completed.externalMatchId).toBe("401234567");
expect(completed.team1ExternalId).toBe("9");
expect(completed.team1Name).toBe("Boston Red Sox");
expect(completed.team2ExternalId).toBe("4");
expect(completed.team2Name).toBe("New York Yankees");
expect(completed.team1Score).toBe(5);
expect(completed.team2Score).toBe(3);
expect(completed.winnerExternalId).toBe("9");
expect(completed.status).toBe("complete");
expect(completed.completedAt).toBeInstanceOf(Date);
});
it("maps scheduled matches with null scores", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_RESPONSE,
} as Response);
const [, scheduled] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
expect(scheduled.status).toBe("scheduled");
expect(scheduled.winnerExternalId).toBeNull();
expect(scheduled.completedAt).toBeNull();
expect(scheduled.startedAt).toBeNull();
});
it("sets matchStage to null for all ESPN matches", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_RESPONSE,
} as Response);
const matches = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
for (const m of matches) {
expect(m.matchStage).toBeNull();
}
});
it("builds correct ESPN URL with sport path and dates param", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ events: [] }),
} as Response);
await new EspnScheduleAdapter("basketball/nba").fetchMatches("2025");
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("basketball/nba")
);
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("dates=2025")
);
});
it("throws on non-ok API response", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 429,
text: async () => "Too Many Requests",
} as Response);
await expect(new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025")).rejects.toThrow(
"ESPN API error 429"
);
});
it("handles in-progress matches", async () => {
const response = {
events: [
{
id: "999",
date: "2025-04-03T20:00:00Z",
competitions: [
{
competitors: [
{ id: "1", team: { id: "1", displayName: "Home Team" }, score: "2", homeAway: "home" as const },
{ id: "2", team: { id: "2", displayName: "Away Team" }, score: "1", homeAway: "away" as const },
],
status: { type: { name: "STATUS_IN_PROGRESS", completed: false } },
},
],
},
],
};
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => response } as Response);
const [m] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
expect(m.status).toBe("in_progress");
expect(m.startedAt).toBeInstanceOf(Date);
expect(m.completedAt).toBeNull();
});
});

View file

@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { PandaScoreMatchSyncAdapter } from "../pandascore";
const SAMPLE_MATCH = {
id: 42,
name: "Team A vs Team B",
status: "finished",
scheduled_at: "2025-03-15T14:00:00Z",
begin_at: "2025-03-15T14:05:00Z",
end_at: "2025-03-15T15:30:00Z",
number_of_games: 3,
opponents: [
{ opponent: { id: 1, name: "Team A" }, type: "Team" },
{ opponent: { id: 2, name: "Team B" }, type: "Team" },
],
results: [
{ team_id: 1, score: 2 },
{ team_id: 2, score: 1 },
],
winner: { id: 1, name: "Team A" },
winner_id: 1,
games: [
{
id: 100,
position: 1,
map: { name: "Mirage" },
winner: { id: 1 },
teams: [
{ team: { id: 1 }, score: 16 },
{ team: { id: 2 }, score: 14 },
],
status: "finished",
},
{
id: 101,
position: 2,
map: { name: "Inferno" },
winner: { id: 2 },
teams: [
{ team: { id: 1 }, score: 12 },
{ team: { id: 2 }, score: 16 },
],
status: "finished",
},
{
id: 102,
position: 3,
map: { name: "Nuke" },
winner: { id: 1 },
teams: [
{ team: { id: 1 }, score: 16 },
{ team: { id: 2 }, score: 10 },
],
status: "finished",
},
],
tournament: { id: 200, name: "Opening Stage" },
};
describe("PandaScoreMatchSyncAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
process.env.PANDASCORE_API_KEY = "test-key";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.PANDASCORE_API_KEY;
});
it("maps a finished match to MatchRecord", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => [SAMPLE_MATCH] } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => [] } as Response);
const adapter = new PandaScoreMatchSyncAdapter();
const matches = await adapter.fetchMatches("9999");
expect(matches).toHaveLength(1);
const m = matches[0];
expect(m.externalMatchId).toBe("42");
expect(m.team1ExternalId).toBe("1");
expect(m.team1Name).toBe("Team A");
expect(m.team2ExternalId).toBe("2");
expect(m.team2Name).toBe("Team B");
expect(m.team1Score).toBe(2);
expect(m.team2Score).toBe(1);
expect(m.winnerExternalId).toBe("1");
expect(m.status).toBe("complete");
expect(m.isSeries).toBe(true);
});
it("maps tournament name to matchStage", async () => {
const openingMatch = { ...SAMPLE_MATCH, tournament: { id: 1, name: "Opening Stage" } };
const challengersMatch = { ...SAMPLE_MATCH, id: 43, tournament: { id: 2, name: "Challengers Stage" } };
const legendsMatch = { ...SAMPLE_MATCH, id: 44, tournament: { id: 3, name: "Legends Stage" } };
vi.mocked(fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => [openingMatch, challengersMatch, legendsMatch],
} as Response)
.mockResolvedValueOnce({ ok: true, json: async () => [] } as Response);
const matches = await new PandaScoreMatchSyncAdapter().fetchMatches("9999");
expect(matches[0].matchStage).toBe(1);
expect(matches[1].matchStage).toBe(2);
expect(matches[2].matchStage).toBe(3);
});
it("includes sub-games with map labels and scores", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => [SAMPLE_MATCH] } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => [] } as Response);
const [m] = await new PandaScoreMatchSyncAdapter().fetchMatches("9999");
expect(m.subGames).toHaveLength(3);
expect(m.subGames![0].gameNumber).toBe(1);
expect(m.subGames![0].gameLabel).toBe("Mirage");
expect(m.subGames![0].team1Score).toBe(16);
expect(m.subGames![0].team2Score).toBe(14);
expect(m.subGames![0].winnerExternalId).toBe("1");
expect(m.subGames![0].status).toBe("complete");
expect(m.subGames![1].gameLabel).toBe("Inferno");
expect(m.subGames![1].winnerExternalId).toBe("2");
});
it("maps status values correctly", async () => {
const inProgressMatch = { ...SAMPLE_MATCH, id: 50, status: "running", winner_id: null, winner: null, end_at: undefined };
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => [inProgressMatch] } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => [] } as Response);
const [m] = await new PandaScoreMatchSyncAdapter().fetchMatches("9999");
expect(m.status).toBe("in_progress");
expect(m.winnerExternalId).toBeNull();
});
it("throws when PANDASCORE_API_KEY is not set", async () => {
delete process.env.PANDASCORE_API_KEY;
await expect(new PandaScoreMatchSyncAdapter().fetchMatches("9999")).rejects.toThrow(
"PANDASCORE_API_KEY"
);
});
it("paginates until an empty page", async () => {
const page1 = Array.from({ length: 100 }, (_, i) => ({
...SAMPLE_MATCH,
id: i + 1,
games: [],
}));
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => page1 } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => [] } as Response);
const matches = await new PandaScoreMatchSyncAdapter().fetchMatches("9999");
expect(matches).toHaveLength(100);
expect(fetch).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,89 @@
import type { MatchRecord, MatchSyncAdapter, MatchStatusValue } from "./types";
interface EspnCompetitor {
id: string;
team: { id: string; displayName: string; abbreviation?: string };
score?: string;
homeAway?: "home" | "away";
winner?: boolean;
}
interface EspnEvent {
id: string;
date: string;
competitions?: Array<{
competitors?: EspnCompetitor[];
status?: { type?: { name?: string; completed?: boolean; description?: string } };
matchday?: number;
}>;
}
interface EspnScheduleResponse {
events?: EspnEvent[];
}
function mapStatus(name: string | undefined, completed: boolean | undefined): MatchStatusValue {
if (completed) return "complete";
if (name === "STATUS_IN_PROGRESS" || name === "in_progress") return "in_progress";
if (name === "STATUS_CANCELED" || name === "canceled") return "canceled";
if (name === "STATUS_POSTPONED" || name === "postponed") return "postponed";
return "scheduled";
}
export class EspnScheduleAdapter implements MatchSyncAdapter {
readonly supportsLiveScores = false;
private readonly sport: string; // e.g. "baseball/mlb", "basketball/nba"
constructor(sport: string) {
this.sport = sport;
}
// externalSeasonId is "YYYY" year string for ESPN sports
async fetchMatches(externalSeasonId: string): Promise<MatchRecord[]> {
const url = `https://site.api.espn.com/apis/site/v2/sports/${this.sport}/scoreboard?limit=1000&dates=${externalSeasonId}`;
const resp = await fetch(url);
if (!resp.ok) {
const body = await resp.text();
throw new Error(`ESPN API error ${resp.status}: ${body}`);
}
const data = (await resp.json()) as EspnScheduleResponse;
const matches: MatchRecord[] = [];
for (const event of data.events ?? []) {
const comp = event.competitions?.[0];
if (!comp) continue;
const competitors = comp.competitors ?? [];
const home = competitors.find((c) => c.homeAway === "home") ?? competitors[0];
const away = competitors.find((c) => c.homeAway === "away") ?? competitors[1];
if (!home || !away) continue;
const statusType = comp.status?.type;
const status = mapStatus(statusType?.name, statusType?.completed);
const homeScore = home.score ? parseInt(home.score, 10) : null;
const awayScore = away.score ? parseInt(away.score, 10) : null;
const winnerExternalId = home.winner ? home.team.id : away.winner ? away.team.id : null;
matches.push({
externalMatchId: event.id,
team1ExternalId: home.team.id,
team1Name: home.team.displayName,
team2ExternalId: away.team.id,
team2Name: away.team.displayName,
team1Score: homeScore,
team2Score: awayScore,
winnerExternalId,
status,
scheduledAt: new Date(event.date),
startedAt: status !== "scheduled" ? new Date(event.date) : null,
completedAt: status === "complete" ? new Date(event.date) : null,
matchStage: null,
matchRound: null,
matchday: comp.matchday ?? null,
isSeries: false,
});
}
return matches;
}
}

View file

@ -0,0 +1,265 @@
import { database } from "~/database/context";
import { eq, isNull, or } from "drizzle-orm";
import * as schema from "~/database/schema";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
import { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match";
import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { processMatchResult, autoCompleteRoundIfDone } from "~/models/scoring-calculator";
import { findMatchingTeamName } from "~/lib/normalize-team-name";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { PandaScoreMatchSyncAdapter } from "./pandascore";
import { EspnScheduleAdapter } from "./espn-schedule";
import type { MatchSyncAdapter, MatchSyncResult, MatchRecord } from "./types";
import { logger } from "~/lib/logger";
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
switch (simulatorType) {
case "cs2_major_qualifying_points":
return new PandaScoreMatchSyncAdapter();
case "mlb_bracket":
return new EspnScheduleAdapter("baseball/mlb");
case "nba_bracket":
return new EspnScheduleAdapter("basketball/nba");
case "mls_bracket":
return new EspnScheduleAdapter("soccer/mls");
case "wnba_bracket":
return new EspnScheduleAdapter("basketball/wnba");
case "nhl_bracket":
return new EspnScheduleAdapter("hockey/nhl");
default:
throw new Error(
`No match sync adapter available for simulator type "${simulatorType}". ` +
"CS2, MLB, NBA, MLS, WNBA, and NHL are currently supported."
);
}
}
export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResult> {
const db = database();
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, sportsSeasonId),
with: { sport: true },
});
if (!sportsSeason) throw new Error(`Sports season ${sportsSeasonId} not found`);
const externalSeasonId = sportsSeason.externalSeasonId;
if (!externalSeasonId) {
throw new Error(`Sports season "${sportsSeason.name}" has no externalSeasonId configured`);
}
const simulatorType = sportsSeason.sport?.simulatorType;
if (!simulatorType) {
throw new Error(`Sport "${sportsSeason.sport?.name ?? "(none)"}" has no simulator type configured`);
}
const adapter = getMatchSyncAdapter(simulatorType);
const fetchedMatches = await adapter.fetchMatches(externalSeasonId);
// Load participants, build lookup maps
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const participantNames = participants.map((p) => p.name);
const participantByExternalId = new Map(
participants.filter((p) => p.externalId).map((p) => [p.externalId!, p])
);
const participantByName = new Map(participants.map((p) => [p.name, p]));
async function resolveParticipant(externalId: string, name: string) {
let participant = participantByExternalId.get(externalId) ?? null;
if (!participant) {
const matchedName = findMatchingTeamName(name, participantNames);
if (matchedName) {
participant = participantByName.get(matchedName) ?? null;
if (participant && !participant.externalId) {
await updateParticipant(participant.id, { externalId });
participantByExternalId.set(externalId, participant);
}
}
}
return participant;
}
// Find the scoring event for this sports season (needed for CS2 stage lookup + playoff sync)
const scoringEvents = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
// Split matches: Swiss (matchStage set) vs playoff bracket (matchStage null)
const swissMatches = fetchedMatches.filter((m) => m.matchStage != null);
const bracketMatches = fetchedMatches.filter((m) => m.matchStage == null);
const unmatchedTeams: MatchSyncResult["unmatchedTeams"] = [];
const errors: MatchSyncResult["errors"] = [];
let swissCreated = 0;
let swissUpdated = 0;
let playoffUpdated = 0;
// --- Swiss matches → season_matches ---
if (swissMatches.length > 0) {
// For CS2, use the scoring event that has cs2_major_stage_results
const cs2Event = scoringEvents.find((e) => e.isQualifyingEvent) ?? scoringEvents[0] ?? null;
const toUpsert = [];
for (const m of swissMatches) {
const p1 = await resolveParticipant(m.team1ExternalId, m.team1Name);
const p2 = await resolveParticipant(m.team2ExternalId, m.team2Name);
if (!p1) unmatchedTeams.push({ externalId: m.team1ExternalId, name: m.team1Name });
if (!p2) unmatchedTeams.push({ externalId: m.team2ExternalId, name: m.team2Name });
const winnerParticipant = m.winnerExternalId
? (await resolveParticipant(m.winnerExternalId, ""))
: null;
toUpsert.push({
sportsSeasonId,
scoringEventId: cs2Event?.id ?? null,
participant1Id: p1?.id ?? null,
participant2Id: p2?.id ?? null,
winnerId: winnerParticipant?.id ?? null,
participant1Score: m.team1Score,
participant2Score: m.team2Score,
matchStage: m.matchStage ?? null,
matchRound: m.matchRound ?? null,
matchday: m.matchday ?? null,
isSeries: m.isSeries ?? false,
status: m.status,
scheduledAt: m.scheduledAt,
startedAt: m.startedAt,
completedAt: m.completedAt,
externalMatchId: m.externalMatchId,
});
}
const upserted = await upsertSeasonMatchBulk(toUpsert);
// Track created vs updated (rough heuristic: if completedAt is within last 2 min, it's new)
swissCreated = upserted.filter((r) => {
const diff = r.createdAt ? Date.now() - r.createdAt.getTime() : Infinity;
return diff < 120_000;
}).length;
swissUpdated = upserted.length - swissCreated;
// Upsert sub-games (maps) for matches with subGames
for (const m of swissMatches) {
if (!m.subGames?.length) continue;
const upsertedMatch = upserted.find((u) => u.externalMatchId === m.externalMatchId);
if (!upsertedMatch) continue;
for (const g of m.subGames) {
const winnerParticipant = g.winnerExternalId
? (await resolveParticipant(g.winnerExternalId, ""))
: null;
try {
await upsertMatchSubGame({
seasonMatchId: upsertedMatch.id,
gameNumber: g.gameNumber,
gameLabel: g.gameLabel ?? null,
participant1Score: g.team1Score,
participant2Score: g.team2Score,
winnerId: winnerParticipant?.id ?? null,
status: g.status,
externalGameId: g.externalGameId ?? null,
});
} catch (err) {
logger.error(`[match-sync] Error upserting sub-game ${g.gameNumber} for match ${m.externalMatchId}:`, err);
}
}
}
}
// --- Bracket matches → playoff_matches ---
if (bracketMatches.length > 0) {
for (const event of scoringEvents) {
const existingPlayoffMatches = await findPlayoffMatchesByEventId(event.id);
if (existingPlayoffMatches.length === 0) continue;
const bracketTemplate = event.bracketTemplateId
? getBracketTemplate(event.bracketTemplateId)
: null;
for (const m of bracketMatches) {
if (m.status !== "complete") continue;
const p1 = await resolveParticipant(m.team1ExternalId, m.team1Name);
const p2 = await resolveParticipant(m.team2ExternalId, m.team2Name);
if (!p1 || !p2) continue;
const winnerParticipant = m.winnerExternalId
? await resolveParticipant(m.winnerExternalId, "")
: null;
if (!winnerParticipant) continue;
const loserId = winnerParticipant.id === p1.id ? p2.id : p1.id;
// Find the matching playoff_matches row: prefer external_match_id match, fall back to participant ID match
let playoffMatch = existingPlayoffMatches.find(
(pm) => pm.externalMatchId === m.externalMatchId
);
if (!playoffMatch) {
playoffMatch = existingPlayoffMatches.find(
(pm) =>
!pm.isComplete &&
((pm.participant1Id === p1.id && pm.participant2Id === p2.id) ||
(pm.participant1Id === p2.id && pm.participant2Id === p1.id))
);
}
if (!playoffMatch) continue;
if (playoffMatch.isComplete && playoffMatch.winnerId) continue; // idempotent
try {
await setMatchWinner(
playoffMatch.id,
winnerParticipant.id,
loserId,
winnerParticipant.id === p1.id ? (m.team1Score ?? undefined) : (m.team2Score ?? undefined),
winnerParticipant.id === p1.id ? (m.team2Score ?? undefined) : (m.team1Score ?? undefined)
);
// Set external_match_id for future upserts
if (!playoffMatch.externalMatchId) {
await updatePlayoffMatch(playoffMatch.id, { externalMatchId: m.externalMatchId });
}
if (bracketTemplate) {
try {
await advanceWinnerTemplate(playoffMatch.id, winnerParticipant.id, bracketTemplate);
} catch (err) {
if (!(err instanceof Error && err.message.includes("already filled"))) throw err;
}
}
const roundIsScoring = bracketTemplate?.rounds.find(
(r) => r.name === playoffMatch!.round
)?.isScoring;
await processMatchResult({
round: playoffMatch.round,
winnerId: winnerParticipant.id,
loserId,
isScoring: roundIsScoring !== undefined ? roundIsScoring : (playoffMatch.isScoring ?? true),
sportsSeasonId,
bracketTemplateId: event.bracketTemplateId ?? null,
eventId: event.id,
eventName: event.name ?? undefined,
matchId: playoffMatch.id,
loserAdvances: false,
});
await autoCompleteRoundIfDone(event.id, playoffMatch.round, sportsSeasonId, db);
playoffUpdated++;
} catch (err) {
logger.error(`[match-sync] Error processing playoff match ${m.externalMatchId}:`, err);
errors.push({
externalMatchId: m.externalMatchId,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
}
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
}

View file

@ -0,0 +1,137 @@
import type { MatchRecord, MatchSyncAdapter, MatchStatusValue, SubGameRecord } from "./types";
// PandaScore API response types
interface PandaScoreOpponent {
opponent: { id: number; name: string };
type: string;
}
interface PandaScoreResult {
team_id: number;
score: number;
}
interface PandaScoreGame {
id: number;
position: number; // map number (1, 2, 3)
map?: { name?: string };
winner?: { id: number } | null;
winner_type?: string;
teams?: Array<{ team: { id: number }; score?: number }>;
status: string; // "not_started" | "running" | "finished"
begin_at?: string;
end_at?: string;
}
interface PandaScoreTournament {
id: number;
name: string;
}
interface PandaScoreMatch {
id: number;
name: string;
status: string; // "not_started" | "running" | "finished" | "canceled" | "postponed"
scheduled_at?: string;
begin_at?: string;
end_at?: string;
opponents?: PandaScoreOpponent[];
results?: PandaScoreResult[];
winner?: { id: number; name: string } | null;
winner_id?: number | null;
number_of_games?: number;
games?: PandaScoreGame[];
tournament?: PandaScoreTournament;
}
function mapStatus(s: string): MatchStatusValue {
switch (s) {
case "running": return "in_progress";
case "finished": return "complete";
case "canceled": return "canceled";
case "postponed": return "postponed";
default: return "scheduled";
}
}
function tournamentNameToStage(name: string): number | null {
const lower = name.toLowerCase();
if (lower.includes("opening")) return 1;
if (lower.includes("challengers") || lower.includes("elimination")) return 2;
if (lower.includes("legends") || lower.includes("decider")) return 3;
return null;
}
export class PandaScoreMatchSyncAdapter implements MatchSyncAdapter {
readonly supportsLiveScores = true;
async fetchMatches(externalSeasonId: string): Promise<MatchRecord[]> {
const apiKey = process.env.PANDASCORE_API_KEY;
if (!apiKey) throw new Error("PANDASCORE_API_KEY is not configured");
const allMatches: PandaScoreMatch[] = [];
let page = 1;
const perPage = 100;
// externalSeasonId is a PandaScore serie_id for CS2 Majors
while (true) {
const url = `https://api.pandascore.co/csgo/matches?filter[serie_id]=${externalSeasonId}&per_page=${perPage}&page=${page}&sort=scheduled_at`;
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`PandaScore API error ${resp.status}: ${body}`);
}
const batch = (await resp.json()) as PandaScoreMatch[];
if (batch.length === 0) break;
allMatches.push(...batch);
if (batch.length < perPage) break;
page++;
}
return allMatches.map((m) => this.mapMatch(m));
}
private mapMatch(m: PandaScoreMatch): MatchRecord {
const team1 = m.opponents?.[0]?.opponent ?? null;
const team2 = m.opponents?.[1]?.opponent ?? null;
const r1 = m.results?.find((r) => r.team_id === team1?.id);
const r2 = m.results?.find((r) => r.team_id === team2?.id);
const matchStage = m.tournament ? tournamentNameToStage(m.tournament.name) : null;
const subGames: SubGameRecord[] = (m.games ?? []).map((g): SubGameRecord => {
const t1score = g.teams?.find((t) => t.team.id === team1?.id)?.score ?? null;
const t2score = g.teams?.find((t) => t.team.id === team2?.id)?.score ?? null;
const winnerExternalId = g.winner?.id != null ? String(g.winner.id) : null;
return {
externalGameId: String(g.id),
gameNumber: g.position,
gameLabel: g.map?.name ?? undefined,
team1Score: t1score !== undefined ? t1score : null,
team2Score: t2score !== undefined ? t2score : null,
winnerExternalId,
status: g.status === "running" ? "in_progress" : g.status === "finished" ? "complete" : "scheduled",
};
});
return {
externalMatchId: String(m.id),
team1ExternalId: team1 ? String(team1.id) : "",
team1Name: team1?.name ?? "",
team2ExternalId: team2 ? String(team2.id) : "",
team2Name: team2?.name ?? "",
team1Score: r1?.score ?? null,
team2Score: r2?.score ?? null,
winnerExternalId: m.winner_id != null ? String(m.winner_id) : null,
status: mapStatus(m.status),
scheduledAt: m.scheduled_at ? new Date(m.scheduled_at) : null,
startedAt: m.begin_at ? new Date(m.begin_at) : null,
completedAt: m.end_at ? new Date(m.end_at) : null,
matchStage,
matchRound: null, // rounds within a stage require additional API calls or ordering
isSeries: (m.number_of_games ?? 1) > 1,
subGames: subGames.length > 0 ? subGames : undefined,
};
}
}

View file

@ -0,0 +1,44 @@
export type MatchStatusValue = "scheduled" | "in_progress" | "complete" | "canceled" | "postponed";
export interface SubGameRecord {
externalGameId?: string;
gameNumber: number;
gameLabel?: string; // CS2 map name ("Mirage"), etc.
team1Score: number | null;
team2Score: number | null;
winnerExternalId?: string | null;
status: "scheduled" | "in_progress" | "complete";
}
export interface MatchRecord {
externalMatchId: string;
team1ExternalId: string;
team1Name: string;
team2ExternalId: string;
team2Name: string;
team1Score: number | null;
team2Score: number | null;
winnerExternalId: string | null;
status: MatchStatusValue;
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
matchStage?: number | null; // CS2: 1/2/3; null for MLB/NBA bracket matches
matchRound?: number | null; // CS2: round within stage; null for MLB/NBA
matchday?: number | null; // EPL/MLS fixture week
isSeries?: boolean;
subGames?: SubGameRecord[];
}
export interface MatchSyncAdapter {
fetchMatches(externalSeasonId: string): Promise<MatchRecord[]>;
readonly supportsLiveScores: boolean;
}
export interface MatchSyncResult {
swissCreated: number;
swissUpdated: number;
playoffUpdated: number;
unmatchedTeams: { externalId: string; name: string }[];
errors: { externalMatchId: string; error: string }[];
}

View file

@ -701,6 +701,7 @@ export const playoffMatches = pgTable("playoff_matches", {
isScoring: boolean("is_scoring").notNull().default(true), // Does this match affect fantasy points?
templateRound: varchar("template_round", { length: 50 }), // "First Four", "Round of 64", "Elite Eight", etc.
seedInfo: varchar("seed_info", { length: 50 }), // "1 vs 16", "11a vs 11b", etc. (for display)
externalMatchId: varchar("external_match_id", { length: 255 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

View file

@ -10,10 +10,10 @@ The current system tracks tournament stage entry/exit manually with no match-lev
## Status
- [ ] Phase 1 — Generic Data Model + CS2 Admin UI
- [ ] Phase 2 — Generic Match Sync Adapter + Cron Job
- [ ] Phase 3 — Public Tournament & Schedule Display + Live Scores
- [ ] Phase 4 — Automated Playoff Bracket Updates (future)
- [x] Phase 1 — Generic Data Model + CS2 Admin UI ✓
- [x] Phase 2 — Generic Match Sync Adapter + Cron Job
- [x] Phase 3 — Public Tournament & Schedule Display + Live Scores
- [x] Phase 4 — Automated Playoff Bracket Updates ✓
---

View file

@ -0,0 +1 @@
ALTER TABLE "playoff_matches" ADD COLUMN "external_match_id" varchar(255);

File diff suppressed because it is too large Load diff

View file

@ -848,6 +848,13 @@
"when": 1781294866660,
"tag": "0120_tidy_invaders",
"breakpoints": true
},
{
"idx": 121,
"version": "7",
"when": 1781296473277,
"tag": "0121_even_gambit",
"breakpoints": true
}
]
}