claude/great-lovelace-r3bznh (#88)
Co-authored-by: Claude <noreply@anthropic.com> Reviewed-on: #88
This commit is contained in:
parent
058e96e67e
commit
f40144e1e2
27 changed files with 16400 additions and 39 deletions
199
app/components/scoring/Cs2TournamentBracket.tsx
Normal file
199
app/components/scoring/Cs2TournamentBracket.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
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 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: _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;
|
||||||
|
let roundGroup = rounds.get(r);
|
||||||
|
if (!roundGroup) {
|
||||||
|
roundGroup = [];
|
||||||
|
rounds.set(r, roundGroup);
|
||||||
|
}
|
||||||
|
roundGroup.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRounds = [...rounds.entries()].toSorted(([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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
app/components/scoring/MatchSchedule.tsx
Normal file
136
app/components/scoring/MatchSchedule.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups by matchday number when available, or by UTC calendar date (YYYY-MM-DD) for date-based sports.
|
||||||
|
// UTC date keys ensure consistent grouping regardless of the viewer's timezone.
|
||||||
|
function groupByMatchday(matches: MatchWithRelations[]) {
|
||||||
|
const groups = new Map<string, MatchWithRelations[]>();
|
||||||
|
for (const m of matches) {
|
||||||
|
const key = m.matchday !== null
|
||||||
|
? `Matchday ${m.matchday}`
|
||||||
|
: m.scheduledAt?.toISOString().slice(0, 10) ?? "TBD";
|
||||||
|
let group = groups.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = [];
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
group.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 grouped = groupByMatchday(matches);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
app/models/__tests__/season-match.test.ts
Normal file
234
app/models/__tests__/season-match.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("~/database/context", () => ({
|
||||||
|
database: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
upsertSeasonMatch,
|
||||||
|
upsertSeasonMatchBulk,
|
||||||
|
upsertMatchSubGame,
|
||||||
|
} from "../season-match";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
|
const SPORTS_SEASON_ID = "ss-1";
|
||||||
|
const SCORING_EVENT_ID = "evt-1";
|
||||||
|
const P1_ID = "p1";
|
||||||
|
const P2_ID = "p2";
|
||||||
|
const WINNER_ID = "p1";
|
||||||
|
const EXTERNAL_ID = "panda_123";
|
||||||
|
|
||||||
|
function makeUpsertDb(returnValue: object) {
|
||||||
|
const returningFn = vi.fn().mockResolvedValue([returnValue]);
|
||||||
|
const onConflictFn = vi.fn().mockReturnValue({ returning: returningFn });
|
||||||
|
const valuesFn = vi.fn().mockReturnValue({ onConflictDoUpdate: onConflictFn });
|
||||||
|
return {
|
||||||
|
insert: vi.fn().mockReturnValue({ values: valuesFn }),
|
||||||
|
_returning: returningFn,
|
||||||
|
_onConflict: onConflictFn,
|
||||||
|
_values: valuesFn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBulkUpsertDb(returnValues: object[]) {
|
||||||
|
const returningFn = vi.fn().mockResolvedValue(returnValues);
|
||||||
|
const onConflictFn = vi.fn().mockReturnValue({ returning: returningFn });
|
||||||
|
const valuesFn = vi.fn().mockReturnValue({ onConflictDoUpdate: onConflictFn });
|
||||||
|
return {
|
||||||
|
insert: vi.fn().mockReturnValue({ values: valuesFn }),
|
||||||
|
_returning: returningFn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_MATCH = {
|
||||||
|
id: "match-1",
|
||||||
|
sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
scoringEventId: SCORING_EVENT_ID,
|
||||||
|
participant1Id: P1_ID,
|
||||||
|
participant2Id: P2_ID,
|
||||||
|
winnerId: WINNER_ID,
|
||||||
|
participant1Score: 2,
|
||||||
|
participant2Score: 1,
|
||||||
|
matchStage: 1,
|
||||||
|
matchRound: 3,
|
||||||
|
matchday: null,
|
||||||
|
isSeries: true,
|
||||||
|
status: "complete" as const,
|
||||||
|
scheduledAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: new Date("2025-01-15T14:00:00Z"),
|
||||||
|
externalMatchId: EXTERNAL_ID,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("upsertSeasonMatch", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a match and returns it", async () => {
|
||||||
|
const mockDb = makeUpsertDb(SAMPLE_MATCH);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||||
|
|
||||||
|
const result = await upsertSeasonMatch({
|
||||||
|
sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
scoringEventId: SCORING_EVENT_ID,
|
||||||
|
participant1Id: P1_ID,
|
||||||
|
participant2Id: P2_ID,
|
||||||
|
winnerId: WINNER_ID,
|
||||||
|
participant1Score: 2,
|
||||||
|
participant2Score: 1,
|
||||||
|
matchStage: 1,
|
||||||
|
matchRound: 3,
|
||||||
|
isSeries: true,
|
||||||
|
status: "complete",
|
||||||
|
completedAt: new Date("2025-01-15T14:00:00Z"),
|
||||||
|
externalMatchId: EXTERNAL_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.id).toBe("match-1");
|
||||||
|
expect(result.matchStage).toBe(1);
|
||||||
|
expect(result.matchRound).toBe(3);
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
expect(mockDb.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(mockDb._values).toHaveBeenCalledOnce();
|
||||||
|
expect(mockDb._onConflict).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults isSeries to false when not provided", async () => {
|
||||||
|
const expectedMatch = { ...SAMPLE_MATCH, isSeries: false };
|
||||||
|
const mockDb = makeUpsertDb(expectedMatch);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||||
|
|
||||||
|
const result = await upsertSeasonMatch({
|
||||||
|
sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
externalMatchId: "ext_1",
|
||||||
|
status: "scheduled",
|
||||||
|
});
|
||||||
|
|
||||||
|
const valuesCall = mockDb._values.mock.calls[0][0];
|
||||||
|
expect(valuesCall.isSeries).toBe(false);
|
||||||
|
expect(result.isSeries).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows null matchStage and matchRound for non-CS2 sports", async () => {
|
||||||
|
const mlbMatch = { ...SAMPLE_MATCH, matchStage: null, matchRound: null, matchday: null, isSeries: false };
|
||||||
|
const mockDb = makeUpsertDb(mlbMatch);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||||
|
|
||||||
|
const result = await upsertSeasonMatch({
|
||||||
|
sportsSeasonId: SPORTS_SEASON_ID,
|
||||||
|
externalMatchId: "mlb_game_456",
|
||||||
|
status: "complete",
|
||||||
|
participant1Score: 5,
|
||||||
|
participant2Score: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const valuesCall = mockDb._values.mock.calls[0][0];
|
||||||
|
expect(valuesCall.matchStage).toBeUndefined();
|
||||||
|
expect(valuesCall.matchRound).toBeUndefined();
|
||||||
|
expect(result.matchStage).toBeNull();
|
||||||
|
expect(result.matchRound).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertSeasonMatchBulk", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for empty input", async () => {
|
||||||
|
vi.mocked(database).mockReturnValue({} as ReturnType<typeof database>);
|
||||||
|
const result = await upsertSeasonMatchBulk([]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulk upserts multiple matches", async () => {
|
||||||
|
const match2 = { ...SAMPLE_MATCH, id: "match-2", matchRound: 4, externalMatchId: "panda_456" };
|
||||||
|
const mockDb = makeBulkUpsertDb([SAMPLE_MATCH, match2]);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||||
|
|
||||||
|
const result = await upsertSeasonMatchBulk([
|
||||||
|
{ sportsSeasonId: SPORTS_SEASON_ID, externalMatchId: EXTERNAL_ID, status: "complete", matchStage: 1, matchRound: 3 },
|
||||||
|
{ sportsSeasonId: SPORTS_SEASON_ID, externalMatchId: "panda_456", status: "complete", matchStage: 1, matchRound: 4 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(mockDb.insert).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertMatchSubGame", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts a map sub-game", async () => {
|
||||||
|
const subGame = {
|
||||||
|
id: "sg-1",
|
||||||
|
seasonMatchId: "match-1",
|
||||||
|
gameNumber: 1,
|
||||||
|
gameLabel: "Mirage",
|
||||||
|
participant1Score: 16,
|
||||||
|
participant2Score: 14,
|
||||||
|
winnerId: P1_ID,
|
||||||
|
status: "complete" as const,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: new Date(),
|
||||||
|
externalGameId: "map_1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
const mockDb = makeUpsertDb(subGame);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||||
|
|
||||||
|
const result = await upsertMatchSubGame({
|
||||||
|
seasonMatchId: "match-1",
|
||||||
|
gameNumber: 1,
|
||||||
|
gameLabel: "Mirage",
|
||||||
|
participant1Score: 16,
|
||||||
|
participant2Score: 14,
|
||||||
|
winnerId: P1_ID,
|
||||||
|
status: "complete",
|
||||||
|
externalGameId: "map_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.gameLabel).toBe("Mirage");
|
||||||
|
expect(result.participant1Score).toBe(16);
|
||||||
|
expect(result.gameNumber).toBe(1);
|
||||||
|
expect(mockDb.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(mockDb._onConflict).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows null gameLabel for non-named sub-games", async () => {
|
||||||
|
const subGame = {
|
||||||
|
id: "sg-2",
|
||||||
|
seasonMatchId: "match-1",
|
||||||
|
gameNumber: 2,
|
||||||
|
gameLabel: null,
|
||||||
|
participant1Score: 3,
|
||||||
|
participant2Score: 1,
|
||||||
|
winnerId: P1_ID,
|
||||||
|
status: "complete" as const,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: new Date(),
|
||||||
|
externalGameId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
const mockDb = makeUpsertDb(subGame);
|
||||||
|
vi.mocked(database).mockReturnValue(mockDb as unknown as ReturnType<typeof database>);
|
||||||
|
|
||||||
|
const result = await upsertMatchSubGame({
|
||||||
|
seasonMatchId: "match-1",
|
||||||
|
gameNumber: 2,
|
||||||
|
participant1Score: 3,
|
||||||
|
participant2Score: 1,
|
||||||
|
status: "complete",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.gameLabel).toBeNull();
|
||||||
|
expect(result.gameNumber).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -23,3 +23,4 @@ export * from "./participant";
|
||||||
export * from "./tournament-result";
|
export * from "./tournament-result";
|
||||||
export * from "./canonical-surface-elo";
|
export * from "./canonical-surface-elo";
|
||||||
export * from "./canonical-golf-skills";
|
export * from "./canonical-golf-skills";
|
||||||
|
export * from "./season-match";
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { getSeasonResults } from "./participant-season-result";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
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 { getUserDisplayName } from "~/models/user";
|
||||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
|
|
@ -1657,3 +1657,23 @@ export async function recalculateAffectedLeagues(
|
||||||
`[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}`
|
`[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}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
232
app/models/season-match.ts
Normal file
232
app/models/season-match.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import { and, asc, eq, isNotNull, sql } from "drizzle-orm";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
export type SeasonMatch = typeof schema.seasonMatches.$inferSelect;
|
||||||
|
export type MatchSubGame = typeof schema.matchSubGames.$inferSelect;
|
||||||
|
export type MatchStatus = (typeof schema.matchStatusEnum.enumValues)[number];
|
||||||
|
|
||||||
|
export interface UpsertSeasonMatchData {
|
||||||
|
sportsSeasonId: string;
|
||||||
|
scoringEventId?: string | null;
|
||||||
|
participant1Id?: string | null;
|
||||||
|
participant2Id?: string | null;
|
||||||
|
winnerId?: string | null;
|
||||||
|
participant1Score?: number | null;
|
||||||
|
participant2Score?: number | null;
|
||||||
|
matchStage?: number | null;
|
||||||
|
matchRound?: number | null;
|
||||||
|
matchday?: number | null;
|
||||||
|
isSeries?: boolean;
|
||||||
|
status: MatchStatus;
|
||||||
|
scheduledAt?: Date | null;
|
||||||
|
startedAt?: Date | null;
|
||||||
|
completedAt?: Date | null;
|
||||||
|
externalMatchId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertMatchSubGameData {
|
||||||
|
seasonMatchId: string;
|
||||||
|
gameNumber: number;
|
||||||
|
gameLabel?: string | null;
|
||||||
|
participant1Score?: number | null;
|
||||||
|
participant2Score?: number | null;
|
||||||
|
winnerId?: string | null;
|
||||||
|
status: MatchStatus;
|
||||||
|
startedAt?: Date | null;
|
||||||
|
completedAt?: Date | null;
|
||||||
|
externalGameId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertSeasonMatch(
|
||||||
|
data: UpsertSeasonMatchData
|
||||||
|
): Promise<SeasonMatch> {
|
||||||
|
const db = database();
|
||||||
|
const now = new Date();
|
||||||
|
const [result] = await db
|
||||||
|
.insert(schema.seasonMatches)
|
||||||
|
.values({
|
||||||
|
...data,
|
||||||
|
isSeries: data.isSeries ?? false,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.seasonMatches.externalMatchId,
|
||||||
|
targetWhere: isNotNull(schema.seasonMatches.externalMatchId),
|
||||||
|
set: {
|
||||||
|
participant1Id: sql`excluded.participant1_id`,
|
||||||
|
participant2Id: sql`excluded.participant2_id`,
|
||||||
|
winnerId: sql`excluded.winner_id`,
|
||||||
|
participant1Score: sql`excluded.participant1_score`,
|
||||||
|
participant2Score: sql`excluded.participant2_score`,
|
||||||
|
matchStage: sql`excluded.match_stage`,
|
||||||
|
matchRound: sql`excluded.match_round`,
|
||||||
|
matchday: sql`excluded.matchday`,
|
||||||
|
isSeries: sql`excluded.is_series`,
|
||||||
|
status: sql`excluded.status`,
|
||||||
|
scheduledAt: sql`excluded.scheduled_at`,
|
||||||
|
startedAt: sql`excluded.started_at`,
|
||||||
|
completedAt: sql`excluded.completed_at`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertSeasonMatchBulk(
|
||||||
|
records: UpsertSeasonMatchData[]
|
||||||
|
): Promise<SeasonMatch[]> {
|
||||||
|
if (records.length === 0) return [];
|
||||||
|
const db = database();
|
||||||
|
const now = new Date();
|
||||||
|
return await db
|
||||||
|
.insert(schema.seasonMatches)
|
||||||
|
.values(records.map((r) => ({ ...r, isSeries: r.isSeries ?? false, updatedAt: now })))
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.seasonMatches.externalMatchId,
|
||||||
|
targetWhere: isNotNull(schema.seasonMatches.externalMatchId),
|
||||||
|
set: {
|
||||||
|
participant1Id: sql`excluded.participant1_id`,
|
||||||
|
participant2Id: sql`excluded.participant2_id`,
|
||||||
|
winnerId: sql`excluded.winner_id`,
|
||||||
|
participant1Score: sql`excluded.participant1_score`,
|
||||||
|
participant2Score: sql`excluded.participant2_score`,
|
||||||
|
matchStage: sql`excluded.match_stage`,
|
||||||
|
matchRound: sql`excluded.match_round`,
|
||||||
|
matchday: sql`excluded.matchday`,
|
||||||
|
isSeries: sql`excluded.is_series`,
|
||||||
|
status: sql`excluded.status`,
|
||||||
|
scheduledAt: sql`excluded.scheduled_at`,
|
||||||
|
startedAt: sql`excluded.started_at`,
|
||||||
|
completedAt: sql`excluded.completed_at`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findSeasonMatchesBySportsSeasonId(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
filters?: { status?: MatchStatus; matchStage?: number }
|
||||||
|
) {
|
||||||
|
const db = database();
|
||||||
|
return await db.query.seasonMatches.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.seasonMatches.sportsSeasonId, sportsSeasonId),
|
||||||
|
filters?.status ? eq(schema.seasonMatches.status, filters.status) : undefined,
|
||||||
|
filters?.matchStage !== undefined
|
||||||
|
? eq(schema.seasonMatches.matchStage, filters.matchStage)
|
||||||
|
: undefined
|
||||||
|
),
|
||||||
|
orderBy: [
|
||||||
|
asc(schema.seasonMatches.matchStage),
|
||||||
|
asc(schema.seasonMatches.matchRound),
|
||||||
|
asc(schema.seasonMatches.matchday),
|
||||||
|
asc(schema.seasonMatches.scheduledAt),
|
||||||
|
asc(schema.seasonMatches.createdAt),
|
||||||
|
],
|
||||||
|
with: {
|
||||||
|
participant1: true,
|
||||||
|
participant2: true,
|
||||||
|
winner: true,
|
||||||
|
subGames: { orderBy: [asc(schema.matchSubGames.gameNumber)] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findSeasonMatchesByScoringEventId(scoringEventId: string) {
|
||||||
|
const db = database();
|
||||||
|
return await db.query.seasonMatches.findMany({
|
||||||
|
where: eq(schema.seasonMatches.scoringEventId, scoringEventId),
|
||||||
|
orderBy: [
|
||||||
|
asc(schema.seasonMatches.matchStage),
|
||||||
|
asc(schema.seasonMatches.matchRound),
|
||||||
|
asc(schema.seasonMatches.scheduledAt),
|
||||||
|
asc(schema.seasonMatches.createdAt),
|
||||||
|
],
|
||||||
|
with: {
|
||||||
|
participant1: true,
|
||||||
|
participant2: true,
|
||||||
|
winner: true,
|
||||||
|
subGames: { orderBy: [asc(schema.matchSubGames.gameNumber)] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findSeasonMatchById(matchId: string) {
|
||||||
|
const db = database();
|
||||||
|
return await db.query.seasonMatches.findFirst({
|
||||||
|
where: eq(schema.seasonMatches.id, matchId),
|
||||||
|
with: {
|
||||||
|
participant1: true,
|
||||||
|
participant2: true,
|
||||||
|
winner: true,
|
||||||
|
subGames: { orderBy: [asc(schema.matchSubGames.gameNumber)] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSeasonMatch(
|
||||||
|
matchId: string,
|
||||||
|
data: Partial<{
|
||||||
|
participant1Id: string | null;
|
||||||
|
participant2Id: string | null;
|
||||||
|
winnerId: string | null;
|
||||||
|
participant1Score: number | null;
|
||||||
|
participant2Score: number | null;
|
||||||
|
status: MatchStatus;
|
||||||
|
scheduledAt: Date | null;
|
||||||
|
startedAt: Date | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
}>
|
||||||
|
): Promise<SeasonMatch> {
|
||||||
|
const db = database();
|
||||||
|
const [result] = await db
|
||||||
|
.update(schema.seasonMatches)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.seasonMatches.id, matchId))
|
||||||
|
.returning();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSeasonMatch(matchId: string): Promise<void> {
|
||||||
|
const db = database();
|
||||||
|
await db
|
||||||
|
.delete(schema.seasonMatches)
|
||||||
|
.where(eq(schema.seasonMatches.id, matchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertMatchSubGame(
|
||||||
|
data: UpsertMatchSubGameData
|
||||||
|
): Promise<MatchSubGame> {
|
||||||
|
const db = database();
|
||||||
|
const now = new Date();
|
||||||
|
const [result] = await db
|
||||||
|
.insert(schema.matchSubGames)
|
||||||
|
.values({ ...data, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [schema.matchSubGames.seasonMatchId, schema.matchSubGames.gameNumber],
|
||||||
|
set: {
|
||||||
|
gameLabel: sql`excluded.game_label`,
|
||||||
|
participant1Score: sql`excluded.participant1_score`,
|
||||||
|
participant2Score: sql`excluded.participant2_score`,
|
||||||
|
winnerId: sql`excluded.winner_id`,
|
||||||
|
status: sql`excluded.status`,
|
||||||
|
startedAt: sql`excluded.started_at`,
|
||||||
|
completedAt: sql`excluded.completed_at`,
|
||||||
|
externalGameId: sql`excluded.external_game_id`,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findMatchSubGamesByMatchId(matchId: string): Promise<MatchSubGame[]> {
|
||||||
|
const db = database();
|
||||||
|
return await db.query.matchSubGames.findMany({
|
||||||
|
where: eq(schema.matchSubGames.seasonMatchId, matchId),
|
||||||
|
orderBy: [asc(schema.matchSubGames.gameNumber)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,10 @@ export default [
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
route("rules", "routes/rules.tsx"),
|
route("rules", "routes/rules.tsx"),
|
||||||
route("sports", "routes/sports.tsx"),
|
route("sports", "routes/sports.tsx"),
|
||||||
|
route(
|
||||||
|
"sports-seasons/:sportsSeasonId/tournament",
|
||||||
|
"routes/sports-seasons.$sportsSeasonId.tournament.tsx"
|
||||||
|
),
|
||||||
route("support", "routes/support.tsx"),
|
route("support", "routes/support.tsx"),
|
||||||
route("upcoming-events", "routes/upcoming-events.tsx"),
|
route("upcoming-events", "routes/upcoming-events.tsx"),
|
||||||
route("privacy-policy", "routes/privacy-policy.tsx"),
|
route("privacy-policy", "routes/privacy-policy.tsx"),
|
||||||
|
|
@ -79,6 +83,7 @@ export default [
|
||||||
// Cron job endpoints
|
// Cron job endpoints
|
||||||
route("admin/jobs/run-daily-snapshots", "routes/admin/jobs.run-daily-snapshots.ts"),
|
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-and-simulate", "routes/admin/jobs.sync-and-simulate.ts"),
|
||||||
|
route("admin/jobs/sync-matches", "routes/admin/jobs.sync-matches.ts"),
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
route("admin", "routes/admin.tsx", [
|
route("admin", "routes/admin.tsx", [
|
||||||
|
|
@ -110,6 +115,10 @@ export default [
|
||||||
"sports-seasons/:id/events/:eventId/cs2-setup",
|
"sports-seasons/:id/events/:eventId/cs2-setup",
|
||||||
"routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx"
|
"routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx"
|
||||||
),
|
),
|
||||||
|
route(
|
||||||
|
"sports-seasons/:id/events/:eventId/swiss-matches",
|
||||||
|
"routes/admin.sports-seasons.$id.events.$eventId.swiss-matches.tsx"
|
||||||
|
),
|
||||||
route(
|
route(
|
||||||
"sports-seasons/:id/expected-values",
|
"sports-seasons/:id/expected-values",
|
||||||
"routes/admin.sports-seasons.$id.expected-values.tsx"
|
"routes/admin.sports-seasons.$id.expected-values.tsx"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
processMatchResult,
|
processMatchResult,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
recalculateStandings,
|
recalculateStandings,
|
||||||
|
autoCompleteRoundIfDone,
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||||
|
|
@ -211,39 +212,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-complete a round when every match in it is marked complete.
|
|
||||||
* Updates scoringEvents.playoffRound and calls processPlayoffEvent so that
|
|
||||||
* round placements are recorded and probabilities are refreshed.
|
|
||||||
* This replaces the manual "Complete Round" button.
|
|
||||||
*
|
|
||||||
* skipRecalculate=true because the caller already sent a Discord notification
|
|
||||||
* scoped to the current batch of matches — we don't want a second one that
|
|
||||||
* would show all previously-completed matches in the event.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
const allDone = roundMatches.every((m) => m.isComplete);
|
|
||||||
if (!allDone) return;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.scoringEvents)
|
|
||||||
.set({ playoffRound: round, updatedAt: new Date() })
|
|
||||||
.where(eq(schema.scoringEvents.id, eventId));
|
|
||||||
|
|
||||||
// skipProbabilities=true: callers (set-winner / set-round-winners) already ran
|
|
||||||
// updateProbabilitiesAfterResult before reaching here, so re-running would be wasted.
|
|
||||||
await processPlayoffEvent(eventId, db, { skipRecalculate: true, skipProbabilities: true });
|
|
||||||
logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "set-winner") {
|
if (intent === "set-winner") {
|
||||||
const matchId = formData.get("matchId");
|
const matchId = formData.get("matchId");
|
||||||
const winnerId = formData.get("winnerId");
|
const winnerId = formData.get("winnerId");
|
||||||
|
|
|
||||||
|
|
@ -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 type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
|
||||||
|
|
||||||
import { findSportsSeasonById } from '~/models/sports-season';
|
import { findSportsSeasonById } from '~/models/sports-season';
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
markCs2StageEliminations,
|
markCs2StageEliminations,
|
||||||
clearCs2StageAssignments,
|
clearCs2StageAssignments,
|
||||||
} from '~/models/cs2-major-stage';
|
} from '~/models/cs2-major-stage';
|
||||||
|
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||||
|
import { syncMatches } from '~/services/match-sync';
|
||||||
import { Button } from '~/components/ui/button';
|
import { Button } from '~/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -19,7 +21,7 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '~/components/ui/card';
|
} from '~/components/ui/card';
|
||||||
import { Badge } from '~/components/ui/badge';
|
import { Badge } from '~/components/ui/badge';
|
||||||
import { ArrowLeft, Save, Trash2, CheckCircle2 } from 'lucide-react';
|
import { ArrowLeft, Save, Trash2, CheckCircle2, RefreshCw } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
|
@ -38,12 +40,13 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
if (!sportsSeason) throw new Response('Sports season not found', { status: 404 });
|
if (!sportsSeason) throw new Response('Sports season not found', { status: 404 });
|
||||||
if (!event) throw new Response('Event not found', { status: 404 });
|
if (!event) throw new Response('Event not found', { status: 404 });
|
||||||
|
|
||||||
const [participants, stageResults] = await Promise.all([
|
const [participants, stageResults, seasonMatches] = await Promise.all([
|
||||||
findParticipantsBySportsSeasonId(sportsSeasonId),
|
findParticipantsBySportsSeasonId(sportsSeasonId),
|
||||||
getCs2StageResultsForEvent(eventId),
|
getCs2StageResultsForEvent(eventId),
|
||||||
|
findSeasonMatchesByScoringEventId(eventId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { sportsSeason, event, participants, stageResults };
|
return { sportsSeason, event, participants, stageResults, seasonMatches };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionData {
|
interface ActionData {
|
||||||
|
|
@ -98,6 +101,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === 'sync-matches') {
|
||||||
|
try {
|
||||||
|
const result = await syncMatches(params.id);
|
||||||
|
const total = result.swissCreated + result.swissUpdated;
|
||||||
|
const summary = `Synced: ${total} Swiss match(es), ${result.playoffUpdated} playoff match(es).`;
|
||||||
|
return { success: true, message: summary };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, message: err instanceof Error ? err.message : 'Sync failed.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: false, message: 'Unknown action.' };
|
return { success: false, message: 'Unknown action.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,11 +127,27 @@ const RECORD_OPTIONS = [
|
||||||
{ value: '0', label: '0-3 (earliest exit)' },
|
{ value: '0', label: '0-3 (earliest exit)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MATCH_STATUS_STYLES: Record<string, string> = {
|
||||||
|
scheduled: 'text-muted-foreground',
|
||||||
|
in_progress: 'text-amber-400 font-medium',
|
||||||
|
complete: 'text-emerald-400',
|
||||||
|
canceled: 'text-destructive line-through',
|
||||||
|
postponed: 'text-muted-foreground italic',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAGE_NAMES: Record<number, string> = {
|
||||||
|
1: 'Opening Stage',
|
||||||
|
2: 'Challengers Stage',
|
||||||
|
3: 'Legends Stage',
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminCs2Setup() {
|
export default function AdminCs2Setup() {
|
||||||
const { sportsSeason, event, participants, stageResults } = useLoaderData<typeof loader>();
|
const { sportsSeason, event, participants, stageResults, seasonMatches } = useLoaderData<typeof loader>();
|
||||||
const actionData = useActionData<ActionData>();
|
const actionData = useActionData<ActionData>();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const isSubmitting = navigation.state === 'submitting';
|
const isSubmitting = navigation.state === 'submitting';
|
||||||
|
const syncFetcher = useFetcher();
|
||||||
|
const isSyncing = syncFetcher.state !== 'idle';
|
||||||
|
|
||||||
// Stage assignment state (local, submitted via form)
|
// Stage assignment state (local, submitted via form)
|
||||||
const [stageSelections, setStageSelections] = useState<Record<string, string>>(() => {
|
const [stageSelections, setStageSelections] = useState<Record<string, string>>(() => {
|
||||||
|
|
@ -313,6 +343,92 @@ export default function AdminCs2Setup() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Swiss Rounds */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
Swiss Rounds
|
||||||
|
<syncFetcher.Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="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>
|
||||||
|
</syncFetcher.Form>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{sportsSeason.externalSeasonId
|
||||||
|
? `Syncing from external ID: ${sportsSeason.externalSeasonId}`
|
||||||
|
: 'Set an External Season ID on the sports season to enable API sync.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{seasonMatches.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No match data yet. Sync from the API or use the <Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/swiss-matches`} className="underline">manual match entry</Link> page.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[1, 2, 3].map(stage => {
|
||||||
|
const stageMatches = seasonMatches.filter(m => m.matchStage === stage);
|
||||||
|
if (stageMatches.length === 0) return null;
|
||||||
|
|
||||||
|
// Group by round
|
||||||
|
const rounds = new Map<number, typeof stageMatches>();
|
||||||
|
for (const m of stageMatches) {
|
||||||
|
const r = m.matchRound ?? 0;
|
||||||
|
let roundGroup = rounds.get(r);
|
||||||
|
if (!roundGroup) {
|
||||||
|
roundGroup = [];
|
||||||
|
rounds.set(r, roundGroup);
|
||||||
|
}
|
||||||
|
roundGroup.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stage}>
|
||||||
|
<h3 className="text-sm font-semibold mb-3">
|
||||||
|
Stage {stage} — {STAGE_NAMES[stage] ?? ''}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...rounds.entries()].toSorted(([a], [b]) => a - b).map(([round, matches]) => (
|
||||||
|
<div key={round}>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||||
|
Round {round}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{matches.map(m => (
|
||||||
|
<div key={m.id} className={`flex items-center gap-3 text-sm py-1 ${MATCH_STATUS_STYLES[m.status] ?? ''}`}>
|
||||||
|
<span className="flex-1">{m.participant1?.name ?? '?'}</span>
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{m.status === 'complete' || m.status === 'in_progress'
|
||||||
|
? `${m.participant1Score ?? '–'} – ${m.participant2Score ?? '–'}`
|
||||||
|
: 'vs'}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-right">{m.participant2?.name ?? '?'}</span>
|
||||||
|
{m.subGames.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
[{m.subGames.map(g => `${g.participant1Score}-${g.participant2Score}`).join(', ')}]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Champions Stage link */}
|
{/* Champions Stage link */}
|
||||||
{stageResults.length > 0 && (
|
{stageResults.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
import { Form, useLoaderData, useActionData, useNavigation, Link } from 'react-router';
|
||||||
|
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.swiss-matches';
|
||||||
|
|
||||||
|
import { findSportsSeasonById } from '~/models/sports-season';
|
||||||
|
import { getScoringEventById } from '~/models/scoring-event';
|
||||||
|
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||||
|
import {
|
||||||
|
findSeasonMatchesByScoringEventId,
|
||||||
|
upsertSeasonMatch,
|
||||||
|
updateSeasonMatch,
|
||||||
|
deleteSeasonMatch,
|
||||||
|
upsertMatchSubGame,
|
||||||
|
} from '~/models/season-match';
|
||||||
|
import type { MatchStatus } from '~/models/season-match';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '~/components/ui/card';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { ArrowLeft, Plus, Save, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
return [{ title: `Swiss Matches — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
const sportsSeasonId = params.id;
|
||||||
|
const eventId = params.eventId;
|
||||||
|
|
||||||
|
const [sportsSeason, event] = await Promise.all([
|
||||||
|
findSportsSeasonById(sportsSeasonId),
|
||||||
|
getScoringEventById(eventId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!sportsSeason) throw new Response('Sports season not found', { status: 404 });
|
||||||
|
if (!event) throw new Response('Event not found', { status: 404 });
|
||||||
|
|
||||||
|
const [participants, matches] = await Promise.all([
|
||||||
|
findParticipantsBySportsSeasonId(sportsSeasonId),
|
||||||
|
findSeasonMatchesByScoringEventId(eventId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { sportsSeason, event, participants, matches };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionData {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
const eventId = params.eventId;
|
||||||
|
const sportsSeasonId = params.id;
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get('intent') as string;
|
||||||
|
|
||||||
|
if (intent === 'create-match') {
|
||||||
|
const participant1Id = formData.get('participant1Id') as string;
|
||||||
|
const participant2Id = formData.get('participant2Id') as string;
|
||||||
|
const matchStage = parseInt(formData.get('matchStage') as string, 10);
|
||||||
|
const matchRound = parseInt(formData.get('matchRound') as string, 10);
|
||||||
|
const isSeries = formData.get('isSeries') === 'true';
|
||||||
|
|
||||||
|
if (!participant1Id || !participant2Id || isNaN(matchStage) || isNaN(matchRound)) {
|
||||||
|
return { success: false, message: 'Missing required fields.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalMatchId = `manual_${eventId}_s${matchStage}_r${matchRound}_${participant1Id}_${participant2Id}`;
|
||||||
|
await upsertSeasonMatch({
|
||||||
|
sportsSeasonId,
|
||||||
|
scoringEventId: eventId,
|
||||||
|
participant1Id,
|
||||||
|
participant2Id,
|
||||||
|
matchStage,
|
||||||
|
matchRound,
|
||||||
|
isSeries,
|
||||||
|
status: 'scheduled',
|
||||||
|
externalMatchId,
|
||||||
|
});
|
||||||
|
return { success: true, message: 'Match created.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === 'update-result') {
|
||||||
|
const matchId = formData.get('matchId') as string;
|
||||||
|
const participant1Score = formData.get('participant1Score');
|
||||||
|
const participant2Score = formData.get('participant2Score');
|
||||||
|
const winnerId = formData.get('winnerId') as string | null;
|
||||||
|
const status = formData.get('status') as MatchStatus;
|
||||||
|
|
||||||
|
const p1Score = participant1Score ? parseInt(participant1Score as string, 10) : null;
|
||||||
|
const p2Score = participant2Score ? parseInt(participant2Score as string, 10) : null;
|
||||||
|
|
||||||
|
await updateSeasonMatch(matchId, {
|
||||||
|
participant1Score: p1Score,
|
||||||
|
participant2Score: p2Score,
|
||||||
|
winnerId: winnerId || null,
|
||||||
|
status,
|
||||||
|
completedAt: status === 'complete' ? new Date() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle sub-games (maps) if provided
|
||||||
|
const mapCount = parseInt(formData.get('mapCount') as string ?? '0', 10);
|
||||||
|
for (let i = 1; i <= mapCount; i++) {
|
||||||
|
const mapLabel = formData.get(`map${i}_label`) as string | null;
|
||||||
|
const map1Score = formData.get(`map${i}_p1`) as string | null;
|
||||||
|
const map2Score = formData.get(`map${i}_p2`) as string | null;
|
||||||
|
if (map1Score !== null && map2Score !== null) {
|
||||||
|
await upsertMatchSubGame({
|
||||||
|
seasonMatchId: matchId,
|
||||||
|
gameNumber: i,
|
||||||
|
gameLabel: mapLabel || null,
|
||||||
|
participant1Score: parseInt(map1Score, 10),
|
||||||
|
participant2Score: parseInt(map2Score, 10),
|
||||||
|
status: 'complete',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Result saved.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === 'delete-match') {
|
||||||
|
const matchId = formData.get('matchId') as string;
|
||||||
|
await deleteSeasonMatch(matchId);
|
||||||
|
return { success: true, message: 'Match deleted.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: 'Unknown action.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_NAMES: Record<number, string> = {
|
||||||
|
1: 'Opening Stage',
|
||||||
|
2: 'Challengers Stage',
|
||||||
|
3: 'Legends Stage',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<MatchStatus, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
|
||||||
|
scheduled: { label: 'Scheduled', variant: 'outline' },
|
||||||
|
in_progress: { label: 'Live', variant: 'default' },
|
||||||
|
complete: { label: 'Complete', variant: 'secondary' },
|
||||||
|
canceled: { label: 'Canceled', variant: 'destructive' },
|
||||||
|
postponed: { label: 'Postponed', variant: 'outline' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminSwissMatches() {
|
||||||
|
const { sportsSeason, event, participants, matches } = useLoaderData<typeof loader>();
|
||||||
|
const actionData = useActionData<ActionData>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const isSubmitting = navigation.state === 'submitting';
|
||||||
|
|
||||||
|
// Group matches by stage → round
|
||||||
|
const byStage = new Map<number, Map<number, typeof matches>>();
|
||||||
|
for (const m of matches) {
|
||||||
|
const stage = m.matchStage ?? 0;
|
||||||
|
const round = m.matchRound ?? 0;
|
||||||
|
let stageMap = byStage.get(stage);
|
||||||
|
if (!stageMap) {
|
||||||
|
stageMap = new Map();
|
||||||
|
byStage.set(stage, stageMap);
|
||||||
|
}
|
||||||
|
let roundList = stageMap.get(round);
|
||||||
|
if (!roundList) {
|
||||||
|
roundList = [];
|
||||||
|
stageMap.set(round, roundList);
|
||||||
|
}
|
||||||
|
roundList.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/cs2-setup`}
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to CS2 Setup
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold mb-1">Swiss Match Entry</h1>
|
||||||
|
<p className="text-muted-foreground">{event.name} — {sportsSeason.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.message && (
|
||||||
|
<div className={`mb-4 p-3 rounded-md text-sm ${actionData.success ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30' : 'bg-destructive/10 text-destructive border border-destructive/30'}`}>
|
||||||
|
{actionData.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create new match */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Match</CardTitle>
|
||||||
|
<CardDescription>Manually add a Swiss round match.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<input type="hidden" name="intent" value="create-match" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Stage</label>
|
||||||
|
<select name="matchStage" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||||
|
<option value="1">Stage 1 — Opening</option>
|
||||||
|
<option value="2">Stage 2 — Challengers</option>
|
||||||
|
<option value="3">Stage 3 — Legends</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Round</label>
|
||||||
|
<select name="matchRound" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||||
|
{[1, 2, 3, 4, 5].map(r => <option key={r} value={r}>Round {r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Format</label>
|
||||||
|
<select name="isSeries" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||||
|
<option value="false">Bo1</option>
|
||||||
|
<option value="true">Bo3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Team 1</label>
|
||||||
|
<select name="participant1Id" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{participants.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Team 2</label>
|
||||||
|
<select name="participant2Id" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{participants.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Match
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Existing matches grouped by stage/round */}
|
||||||
|
{matches.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No matches yet.</p>
|
||||||
|
) : (
|
||||||
|
[...byStage.entries()].toSorted(([a], [b]) => a - b).map(([stage, rounds]) => (
|
||||||
|
<Card key={stage} className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Stage {stage} — {STAGE_NAMES[stage] ?? ''}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{[...rounds.entries()].toSorted(([a], [b]) => a - b).map(([round, roundMatches]) => (
|
||||||
|
<div key={round}>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Round {round}</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{roundMatches.map(match => (
|
||||||
|
<div key={match.id} className="border border-border rounded-md p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{match.participant1?.name ?? '?'} vs {match.participant2?.name ?? '?'}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={STATUS_BADGE[match.status]?.variant ?? 'outline'}>
|
||||||
|
{STATUS_BADGE[match.status]?.label ?? match.status}
|
||||||
|
</Badge>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete-match" />
|
||||||
|
<input type="hidden" name="matchId" value={match.id} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm" className="text-destructive h-7 px-2" disabled={isSubmitting}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Form method="post" className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<input type="hidden" name="intent" value="update-result" />
|
||||||
|
<input type="hidden" name="matchId" value={match.id} />
|
||||||
|
<input type="hidden" name="mapCount" value={match.isSeries ? '3' : '0'} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">{match.participant1?.name ?? 'P1'} score</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="participant1Score"
|
||||||
|
defaultValue={match.participant1Score ?? ''}
|
||||||
|
min="0"
|
||||||
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">{match.participant2?.name ?? 'P2'} score</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="participant2Score"
|
||||||
|
defaultValue={match.participant2Score ?? ''}
|
||||||
|
min="0"
|
||||||
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">Winner</label>
|
||||||
|
<select
|
||||||
|
name="winnerId"
|
||||||
|
defaultValue={match.winnerId ?? ''}
|
||||||
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||||
|
>
|
||||||
|
<option value="">— none —</option>
|
||||||
|
<option value={match.participant1Id ?? ''}>{match.participant1?.name ?? 'P1'}</option>
|
||||||
|
<option value={match.participant2Id ?? ''}>{match.participant2?.name ?? 'P2'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">Status</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={match.status}
|
||||||
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||||||
|
>
|
||||||
|
<option value="scheduled">Scheduled</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="complete">Complete</option>
|
||||||
|
<option value="canceled">Canceled</option>
|
||||||
|
<option value="postponed">Postponed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{match.isSeries && (
|
||||||
|
<>
|
||||||
|
{[1, 2, 3].map(mapNum => {
|
||||||
|
const subGame = match.subGames.find(g => g.gameNumber === mapNum);
|
||||||
|
return (
|
||||||
|
<div key={mapNum} className="col-span-2 grid grid-cols-3 gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`map${mapNum}_label`}
|
||||||
|
placeholder={`Map ${mapNum} name`}
|
||||||
|
defaultValue={subGame?.gameLabel ?? ''}
|
||||||
|
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name={`map${mapNum}_p1`}
|
||||||
|
placeholder="P1"
|
||||||
|
defaultValue={subGame?.participant1Score ?? ''}
|
||||||
|
min="0"
|
||||||
|
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name={`map${mapNum}_p2`}
|
||||||
|
placeholder="P2"
|
||||||
|
defaultValue={subGame?.participant2Score ?? ''}
|
||||||
|
min="0"
|
||||||
|
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="col-span-2 md:col-span-4 flex justify-end">
|
||||||
|
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||||
|
<Save className="h-3 w-3 mr-1" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -315,6 +315,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
const totalMajors = formData.get("totalMajors");
|
const totalMajors = formData.get("totalMajors");
|
||||||
const draftOn = formData.get("draftOn");
|
const draftOn = formData.get("draftOn");
|
||||||
const draftOff = formData.get("draftOff");
|
const draftOff = formData.get("draftOff");
|
||||||
|
const externalSeasonId = formData.get("externalSeasonId");
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (typeof name !== "string" || !name.trim()) {
|
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);
|
await updateSportsSeason(params.id, updateData);
|
||||||
|
|
||||||
return { success: true, message: "Sports season updated successfully!" };
|
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).
|
This season appears in league creation and pre-draft settings only between these two dates (inclusive).
|
||||||
</p>
|
</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 && (
|
{actionData?.error && (
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
{actionData.error}
|
{actionData.error}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
const totalMajors = formData.get("totalMajors");
|
const totalMajors = formData.get("totalMajors");
|
||||||
const draftOn = formData.get("draftOn");
|
const draftOn = formData.get("draftOn");
|
||||||
const draftOff = formData.get("draftOff");
|
const draftOff = formData.get("draftOff");
|
||||||
|
const externalSeasonId = formData.get("externalSeasonId");
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (typeof sportId !== "string" || !sportId) {
|
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);
|
await createSportsSeason(seasonData as NewSportsSeason);
|
||||||
|
|
||||||
return redirect("/admin/sports-seasons");
|
return redirect("/admin/sports-seasons");
|
||||||
|
|
@ -261,6 +266,19 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon
|
||||||
</div>
|
</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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="draftOn">Draft Open Date</Label>
|
<Label htmlFor="draftOn">Draft Open Date</Label>
|
||||||
|
|
|
||||||
41
app/routes/admin/jobs.sync-matches.ts
Normal file
41
app/routes/admin/jobs.sync-matches.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { and, eq, 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: and(
|
||||||
|
isNotNull(schema.sportsSeasons.externalSeasonId),
|
||||||
|
eq(schema.sportsSeasons.status, "active")
|
||||||
|
),
|
||||||
|
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 });
|
||||||
|
}
|
||||||
135
app/routes/sports-seasons.$sportsSeasonId.tournament.tsx
Normal file
135
app/routes/sports-seasons.$sportsSeasonId.tournament.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Link, 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";
|
||||||
|
|
||||||
|
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 && (m.participant1Id !== null || m.participant2Id !== null));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sportsSeason,
|
||||||
|
simulatorType,
|
||||||
|
seasonMatches,
|
||||||
|
playoffMatches,
|
||||||
|
playoffRounds,
|
||||||
|
bracketTemplateId,
|
||||||
|
hasLiveMatches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
app/services/match-sync/__tests__/espn-schedule.test.ts
Normal file
236
app/services/match-sync/__tests__/espn-schedule.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves score of 0 — does not coerce falsy string to null", async () => {
|
||||||
|
const response = {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: "777",
|
||||||
|
date: "2025-04-05T18:00:00Z",
|
||||||
|
competitions: [
|
||||||
|
{
|
||||||
|
competitors: [
|
||||||
|
{ id: "10", team: { id: "10", displayName: "Team A" }, score: "0", homeAway: "home" as const, winner: false },
|
||||||
|
{ id: "11", team: { id: "11", displayName: "Team B" }, score: "3", homeAway: "away" as const, winner: true },
|
||||||
|
],
|
||||||
|
status: { type: { name: "STATUS_FINAL", completed: true } },
|
||||||
|
matchday: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => response } as Response);
|
||||||
|
|
||||||
|
const [m] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
|
||||||
|
expect(m.team1Score).toBe(0);
|
||||||
|
expect(m.team2Score).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null (not NaN) for non-numeric score strings", async () => {
|
||||||
|
const response = {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: "778",
|
||||||
|
date: "2025-04-06T18:00:00Z",
|
||||||
|
competitions: [
|
||||||
|
{
|
||||||
|
competitors: [
|
||||||
|
{ id: "10", team: { id: "10", displayName: "Team A" }, score: "F/OT", homeAway: "home" as const, winner: true },
|
||||||
|
{ id: "11", team: { id: "11", displayName: "Team B" }, score: "TBD", homeAway: "away" as const, winner: false },
|
||||||
|
],
|
||||||
|
status: { type: { name: "STATUS_FINAL", completed: true } },
|
||||||
|
matchday: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => response } as Response);
|
||||||
|
|
||||||
|
const [m] = await new EspnScheduleAdapter("baseball/mlb").fetchMatches("2025");
|
||||||
|
expect(m.team1Score).toBeNull();
|
||||||
|
expect(m.team2Score).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends seasontype param to URL when seasonType is provided", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ events: [] }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await new EspnScheduleAdapter("baseball/mlb", 3).fetchMatches("2025");
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("seasontype=3")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
163
app/services/match-sync/__tests__/pandascore.test.ts
Normal file
163
app/services/match-sync/__tests__/pandascore.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
const subGames = m.subGames ?? [];
|
||||||
|
expect(subGames[0].gameNumber).toBe(1);
|
||||||
|
expect(subGames[0].gameLabel).toBe("Mirage");
|
||||||
|
expect(subGames[0].team1Score).toBe(16);
|
||||||
|
expect(subGames[0].team2Score).toBe(14);
|
||||||
|
expect(subGames[0].winnerExternalId).toBe("1");
|
||||||
|
expect(subGames[0].status).toBe("complete");
|
||||||
|
|
||||||
|
expect(subGames[1].gameLabel).toBe("Inferno");
|
||||||
|
expect(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
app/services/match-sync/espn-schedule.ts
Normal file
96
app/services/match-sync/espn-schedule.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScore(s: string | undefined): number | null {
|
||||||
|
if (s === undefined || s === "") return null;
|
||||||
|
const n = parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EspnScheduleAdapter implements MatchSyncAdapter {
|
||||||
|
readonly supportsLiveScores = false;
|
||||||
|
|
||||||
|
// sport: e.g. "baseball/mlb", "basketball/nba"
|
||||||
|
// seasonType: ESPN season type (1=preseason, 2=regular, 3=postseason). Pass 3 for bracket sports
|
||||||
|
// to avoid the scoreboard limit silently truncating regular-season games.
|
||||||
|
constructor(private readonly sport: string, private readonly seasonType?: number) {}
|
||||||
|
|
||||||
|
// externalSeasonId is "YYYY" year string for ESPN sports
|
||||||
|
async fetchMatches(externalSeasonId: string): Promise<MatchRecord[]> {
|
||||||
|
const seasonTypeParam = this.seasonType !== undefined ? `&seasontype=${this.seasonType}` : "";
|
||||||
|
const url = `https://site.api.espn.com/apis/site/v2/sports/${this.sport}/scoreboard?limit=1000&dates=${externalSeasonId}${seasonTypeParam}`;
|
||||||
|
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 = parseScore(home.score);
|
||||||
|
const awayScore = parseScore(away.score);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
271
app/services/match-sync/index.ts
Normal file
271
app/services/match-sync/index.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import { eq } 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, doesLoserAdvance } 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 } 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", 3);
|
||||||
|
case "nba_bracket":
|
||||||
|
return new EspnScheduleAdapter("basketball/nba", 3);
|
||||||
|
case "mls_bracket":
|
||||||
|
return new EspnScheduleAdapter("soccer/mls", 3);
|
||||||
|
case "wnba_bracket":
|
||||||
|
return new EspnScheduleAdapter("basketball/wnba", 3);
|
||||||
|
case "nhl_bracket":
|
||||||
|
return new EspnScheduleAdapter("hockey/nhl", 3);
|
||||||
|
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 as string, 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 && m.matchStage !== undefined);
|
||||||
|
const bracketMatches = fetchedMatches.filter((m) => m.matchStage === null || m.matchStage === undefined);
|
||||||
|
|
||||||
|
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 && scoringEvents.length === 0) {
|
||||||
|
logger.warn(`[match-sync] ${bracketMatches.length} bracket match(es) found but no scoring events exist for season ${sportsSeasonId} — bracket sync skipped`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: event.bracketTemplateId
|
||||||
|
? doesLoserAdvance(playoffMatch.round, playoffMatch.matchNumber, event.bracketTemplateId)
|
||||||
|
: 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 };
|
||||||
|
}
|
||||||
137
app/services/match-sync/pandascore.ts
Normal file
137
app/services/match-sync/pandascore.ts
Normal 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 !== null && g.winner !== undefined ? 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 && m.winner_id !== undefined ? 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/services/match-sync/types.ts
Normal file
44
app/services/match-sync/types.ts
Normal 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 }[];
|
||||||
|
}
|
||||||
|
|
@ -371,6 +371,14 @@ export const sports = pgTable("sports", {
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const matchStatusEnum = pgEnum("match_status", [
|
||||||
|
"scheduled",
|
||||||
|
"in_progress",
|
||||||
|
"complete",
|
||||||
|
"canceled",
|
||||||
|
"postponed",
|
||||||
|
]);
|
||||||
|
|
||||||
export const sportsSeasons = pgTable("sports_seasons", {
|
export const sportsSeasons = pgTable("sports_seasons", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
sportId: uuid("sport_id")
|
sportId: uuid("sport_id")
|
||||||
|
|
@ -384,6 +392,7 @@ export const sportsSeasons = pgTable("sports_seasons", {
|
||||||
startDate: date("start_date"),
|
startDate: date("start_date"),
|
||||||
endDate: date("end_date"),
|
endDate: date("end_date"),
|
||||||
status: sportsSeasonStatusEnum("status").notNull().default("upcoming"),
|
status: sportsSeasonStatusEnum("status").notNull().default("upcoming"),
|
||||||
|
externalSeasonId: varchar("external_season_id", { length: 255 }),
|
||||||
scoringType: scoringTypeEnum("scoring_type").notNull(),
|
scoringType: scoringTypeEnum("scoring_type").notNull(),
|
||||||
// New scoring pattern field (replaces/augments scoringType)
|
// New scoring pattern field (replaces/augments scoringType)
|
||||||
scoringPattern: scoringPatternEnum("scoring_pattern"),
|
scoringPattern: scoringPatternEnum("scoring_pattern"),
|
||||||
|
|
@ -692,6 +701,7 @@ export const playoffMatches = pgTable("playoff_matches", {
|
||||||
isScoring: boolean("is_scoring").notNull().default(true), // Does this match affect fantasy points?
|
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.
|
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)
|
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(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
@ -1004,6 +1014,7 @@ export const sportsSeasonsRelations = relations(sportsSeasons, ({ one, many }) =
|
||||||
participantResults: many(seasonParticipantResults),
|
participantResults: many(seasonParticipantResults),
|
||||||
regularSeasonStandings: many(regularSeasonStandings),
|
regularSeasonStandings: many(regularSeasonStandings),
|
||||||
pendingStandingsMappings: many(pendingStandingsMappings),
|
pendingStandingsMappings: many(pendingStandingsMappings),
|
||||||
|
seasonMatches: many(seasonMatches),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const simulatorProfilesRelations = relations(simulatorProfiles, ({ many }) => ({
|
export const simulatorProfilesRelations = relations(simulatorProfiles, ({ many }) => ({
|
||||||
|
|
@ -1238,6 +1249,7 @@ export const scoringEventsRelations = relations(scoringEvents, ({ one, many }) =
|
||||||
playoffMatches: many(playoffMatches),
|
playoffMatches: many(playoffMatches),
|
||||||
tournamentGroups: many(tournamentGroups),
|
tournamentGroups: many(tournamentGroups),
|
||||||
cs2MajorStageResults: many(cs2MajorStageResults),
|
cs2MajorStageResults: many(cs2MajorStageResults),
|
||||||
|
seasonMatches: many(seasonMatches),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
export const eventResultsRelations = relations(eventResults, ({ one }) => ({
|
||||||
|
|
@ -1619,6 +1631,111 @@ export const cs2MajorStageResultsRelations = relations(cs2MajorStageResults, ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ─── Season Matches (Generic) ──────────────────────────────────────────────────
|
||||||
|
// Generic match records for regular season games, Swiss rounds, group stage
|
||||||
|
// fixtures, and any other "two-participant matchup with a score" format.
|
||||||
|
//
|
||||||
|
// CS2 Swiss: match_stage (1-3) + match_round (1-5), is_series = true
|
||||||
|
// MLB/NBA etc: match_stage/match_round = NULL, matchday = NULL or week number
|
||||||
|
// EPL/MLS: matchday = fixture week, match_stage/match_round = NULL
|
||||||
|
//
|
||||||
|
// Playoff bracket matches (with bracket progression logic) stay in playoff_matches.
|
||||||
|
|
||||||
|
export const seasonMatches = pgTable("season_matches", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sportsSeasonId: uuid("sports_season_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||||
|
scoringEventId: uuid("scoring_event_id")
|
||||||
|
.references(() => scoringEvents.id, { onDelete: "cascade" }),
|
||||||
|
participant1Id: uuid("participant1_id")
|
||||||
|
.references(() => seasonParticipants.id, { onDelete: "set null" }),
|
||||||
|
participant2Id: uuid("participant2_id")
|
||||||
|
.references(() => seasonParticipants.id, { onDelete: "set null" }),
|
||||||
|
winnerId: uuid("winner_id")
|
||||||
|
.references(() => seasonParticipants.id, { onDelete: "set null" }),
|
||||||
|
participant1Score: integer("participant1_score"),
|
||||||
|
participant2Score: integer("participant2_score"),
|
||||||
|
matchStage: integer("match_stage"), // CS2: 1/2/3 for Opening/Challengers/Legends
|
||||||
|
matchRound: integer("match_round"), // CS2: round within stage (1-5)
|
||||||
|
matchday: integer("matchday"), // EPL/MLS fixture week
|
||||||
|
isSeries: boolean("is_series").notNull().default(false),
|
||||||
|
status: matchStatusEnum("status").notNull().default("scheduled"),
|
||||||
|
scheduledAt: timestamp("scheduled_at"),
|
||||||
|
startedAt: timestamp("started_at"),
|
||||||
|
completedAt: timestamp("completed_at"),
|
||||||
|
externalMatchId: varchar("external_match_id", { length: 255 }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
}, (t) => [
|
||||||
|
uniqueIndex("season_matches_external_id_unique")
|
||||||
|
.on(t.externalMatchId)
|
||||||
|
.where(sql`${t.externalMatchId} IS NOT NULL`),
|
||||||
|
index("season_matches_sports_season_status_idx").on(t.sportsSeasonId, t.status),
|
||||||
|
index("season_matches_sports_season_stage_round_idx").on(t.sportsSeasonId, t.matchStage, t.matchRound),
|
||||||
|
index("season_matches_scoring_event_idx").on(t.scoringEventId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const matchSubGames = pgTable("match_sub_games", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
seasonMatchId: uuid("season_match_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => seasonMatches.id, { onDelete: "cascade" }),
|
||||||
|
gameNumber: integer("game_number").notNull(),
|
||||||
|
gameLabel: varchar("game_label", { length: 100 }), // CS2 map name, "Game 1", etc.
|
||||||
|
participant1Score: integer("participant1_score"),
|
||||||
|
participant2Score: integer("participant2_score"),
|
||||||
|
winnerId: uuid("winner_id")
|
||||||
|
.references(() => seasonParticipants.id, { onDelete: "set null" }),
|
||||||
|
status: matchStatusEnum("status").notNull().default("scheduled"),
|
||||||
|
startedAt: timestamp("started_at"),
|
||||||
|
completedAt: timestamp("completed_at"),
|
||||||
|
externalGameId: varchar("external_game_id", { length: 255 }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
}, (t) => [
|
||||||
|
uniqueIndex("match_sub_games_match_game_number_unique").on(t.seasonMatchId, t.gameNumber),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const seasonMatchesRelations = relations(seasonMatches, ({ one, many }) => ({
|
||||||
|
sportsSeason: one(sportsSeasons, {
|
||||||
|
fields: [seasonMatches.sportsSeasonId],
|
||||||
|
references: [sportsSeasons.id],
|
||||||
|
}),
|
||||||
|
scoringEvent: one(scoringEvents, {
|
||||||
|
fields: [seasonMatches.scoringEventId],
|
||||||
|
references: [scoringEvents.id],
|
||||||
|
}),
|
||||||
|
participant1: one(seasonParticipants, {
|
||||||
|
fields: [seasonMatches.participant1Id],
|
||||||
|
references: [seasonParticipants.id],
|
||||||
|
relationName: "seasonMatchParticipant1",
|
||||||
|
}),
|
||||||
|
participant2: one(seasonParticipants, {
|
||||||
|
fields: [seasonMatches.participant2Id],
|
||||||
|
references: [seasonParticipants.id],
|
||||||
|
relationName: "seasonMatchParticipant2",
|
||||||
|
}),
|
||||||
|
winner: one(seasonParticipants, {
|
||||||
|
fields: [seasonMatches.winnerId],
|
||||||
|
references: [seasonParticipants.id],
|
||||||
|
relationName: "seasonMatchWinner",
|
||||||
|
}),
|
||||||
|
subGames: many(matchSubGames),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const matchSubGamesRelations = relations(matchSubGames, ({ one }) => ({
|
||||||
|
match: one(seasonMatches, {
|
||||||
|
fields: [matchSubGames.seasonMatchId],
|
||||||
|
references: [seasonMatches.id],
|
||||||
|
}),
|
||||||
|
winner: one(seasonParticipants, {
|
||||||
|
fields: [matchSubGames.winnerId],
|
||||||
|
references: [seasonParticipants.id],
|
||||||
|
relationName: "matchSubGameWinner",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// ─── Commissioner Audit Log ────────────────────────────────────────────────────
|
// ─── Commissioner Audit Log ────────────────────────────────────────────────────
|
||||||
// Immutable log of significant commissioner/admin actions per season.
|
// Immutable log of significant commissioner/admin actions per season.
|
||||||
// Readable by all league members for transparency; writable only by the
|
// Readable by all league members for transparency; writable only by the
|
||||||
|
|
|
||||||
255
docs/plans/match-sync-display.md
Normal file
255
docs/plans/match-sync-display.md
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
# Plan: Generalized Match Import, Swiss Stage Tracking, Live Scores, and Display System
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current system tracks tournament stage entry/exit manually with no match-level data, no automated schedule/result imports, and no public tournament display. The goal is a sport-agnostic match sync infrastructure (usable for MLB schedules, CS2 Swiss rounds, EPL fixtures, etc.) with CS2 as the first implementation target.
|
||||||
|
|
||||||
|
**Scope decision**: The new `season_matches` table is generic from day one. CS2 Swiss uses nullable `match_stage`/`match_round` columns; MLB just leaves those NULL. Playoff bracket matches remain in the existing `playoff_matches` table (already works well with complex progression logic). This avoids proliferating per-sport match tables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [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 ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Generic Data Model + CS2 Admin UI (no external API)
|
||||||
|
|
||||||
|
### 1a. Schema additions (`database/schema.ts`)
|
||||||
|
|
||||||
|
**New enum**: `matchStatusEnum` = `["scheduled", "in_progress", "complete", "canceled", "postponed"]`
|
||||||
|
|
||||||
|
**New table: `season_matches`** — generic match record for regular season, Swiss rounds, group stages, fixtures
|
||||||
|
```
|
||||||
|
id (uuid PK)
|
||||||
|
sports_season_id (uuid FK → sportsSeasons, onDelete: cascade)
|
||||||
|
scoring_event_id (uuid FK → scoringEvents, nullable — set for CS2 stage matches, null for MLB regular season)
|
||||||
|
participant1_id (uuid FK → seasonParticipants, nullable)
|
||||||
|
participant2_id (uuid FK → seasonParticipants, nullable)
|
||||||
|
winner_id (uuid FK → seasonParticipants, nullable)
|
||||||
|
participant1_score (integer nullable)
|
||||||
|
participant2_score (integer nullable)
|
||||||
|
match_stage (integer nullable) — CS2: 1/2/3 for Opening/Challengers/Legends; NULL for MLB
|
||||||
|
match_round (integer nullable) — CS2: round within stage (1-5); MLB: series game number or NULL
|
||||||
|
matchday (integer nullable) — EPL/MLS fixture week; NULL for CS2
|
||||||
|
is_series (boolean default false) — true for Bo3/Bo5 CS2 matches
|
||||||
|
status (matchStatusEnum default 'scheduled')
|
||||||
|
scheduled_at (timestamp nullable)
|
||||||
|
started_at (timestamp nullable)
|
||||||
|
completed_at (timestamp nullable)
|
||||||
|
external_match_id (varchar 255 nullable) — API ID for upsert conflict key
|
||||||
|
created_at, updated_at (timestamps)
|
||||||
|
```
|
||||||
|
Indexes: unique on `external_match_id` (partial, WHERE NOT NULL), index on `(sports_season_id, status)`, index on `(sports_season_id, match_stage, match_round)`, index on `(scoring_event_id)`.
|
||||||
|
|
||||||
|
**New table: `match_sub_games`** — per-sub-game scores (CS2 maps, MLB innings if desired, etc.)
|
||||||
|
```
|
||||||
|
id (uuid PK)
|
||||||
|
season_match_id (uuid FK → seasonMatches, onDelete: cascade)
|
||||||
|
game_number (integer) — map 1/2/3 for CS2, game 1-7 for series, inning for baseball
|
||||||
|
game_label (varchar 100 nullable) — CS2: "Mirage", "Inferno"; others: NULL or "Game 1"
|
||||||
|
participant1_score (integer nullable)
|
||||||
|
participant2_score (integer nullable)
|
||||||
|
winner_id (uuid FK → seasonParticipants, nullable)
|
||||||
|
status (matchStatusEnum default 'scheduled')
|
||||||
|
started_at, completed_at (timestamps nullable)
|
||||||
|
external_game_id (varchar 255 nullable)
|
||||||
|
```
|
||||||
|
Unique index on `(season_match_id, game_number)`.
|
||||||
|
|
||||||
|
**New column on `sportsSeasons`**: `external_season_id varchar(255) nullable` — links to the API's identifier for this season/tournament (PandaScore tournament ID for CS2, ESPN league season ID for MLB, etc.)
|
||||||
|
|
||||||
|
Add Drizzle `relations()` for both new tables following the existing patterns.
|
||||||
|
|
||||||
|
Run `npm run db:generate` after changes.
|
||||||
|
|
||||||
|
### 1b. New model: `app/models/season-match.ts`
|
||||||
|
- `upsertSeasonMatch(data)` — conflict on `external_match_id`, update scores/status/timestamps
|
||||||
|
- `findSeasonMatchesBySportsSeasonId(sportsSeasonId, filters?)` — joined with participant names
|
||||||
|
- `findSeasonMatchesByScoringEventId(scoringEventId)` — for CS2 stage view, ordered stage → round
|
||||||
|
- `findSeasonMatchesByStage(sportsSeasonId, stage)` — CS2 per-stage query
|
||||||
|
- `upsertMatchSubGame(data)`
|
||||||
|
- `findMatchSubGamesByMatchId(matchId)`
|
||||||
|
|
||||||
|
### 1c. Extend cs2-setup admin route
|
||||||
|
File: `app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx`
|
||||||
|
- Add loader query calling `findSeasonMatchesByScoringEventId`
|
||||||
|
- Add read-only Swiss rounds section below existing stage assignment UI: per round shows `Team A (W-L) vs Team B (W-L) → maps [16-14, 14-16, 16-12]`
|
||||||
|
- "Sync from API" button (fetcher POST to cron endpoint, wired in Phase 2)
|
||||||
|
|
||||||
|
### 1d. New admin route: manual match entry
|
||||||
|
File: `app/routes/admin.sports-seasons.$id.events.$eventId.swiss-matches.tsx`
|
||||||
|
Actions: `update-match-result`, `create-manual-match`, `delete-match` — manual fallback when API unavailable
|
||||||
|
Register in `app/routes.ts` under admin layout.
|
||||||
|
|
||||||
|
### 1e. Tests
|
||||||
|
- `app/models/__tests__/season-match.test.ts` — upsert conflict resolution, ordering, null stage/round handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Generic Match Sync Adapter + Cron Job
|
||||||
|
|
||||||
|
### External Data Sources
|
||||||
|
- **CS2** (and future Dota2/LoL): **PandaScore** — free tier, 1,000 req/hour, no credit card needed. `PANDASCORE_API_KEY` env var.
|
||||||
|
- **MLB/NBA/NFL/MLS/NHL etc.**: ESPN public API (already used for standings), or sport-specific APIs already in `standings-sync/`. Same free, no-auth pattern.
|
||||||
|
|
||||||
|
### 2a. New service directory: `app/services/match-sync/`
|
||||||
|
|
||||||
|
**`types.ts`** — generic adapter interface:
|
||||||
|
```typescript
|
||||||
|
interface MatchRecord {
|
||||||
|
externalMatchId: string;
|
||||||
|
team1ExternalId: string;
|
||||||
|
team2ExternalId: string;
|
||||||
|
team1Score: number | null;
|
||||||
|
team2Score: number | null;
|
||||||
|
winnerExternalId: string | null;
|
||||||
|
status: "scheduled" | "in_progress" | "complete" | "canceled" | "postponed";
|
||||||
|
scheduledAt: Date | null;
|
||||||
|
startedAt: Date | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
matchStage?: number | null; // CS2: 1/2/3; omit for MLB
|
||||||
|
matchRound?: number | null; // CS2: round within stage; omit for MLB
|
||||||
|
matchday?: number | null; // EPL/MLS fixture week
|
||||||
|
isSeries?: boolean;
|
||||||
|
subGames?: SubGameRecord[];
|
||||||
|
}
|
||||||
|
interface SubGameRecord {
|
||||||
|
externalGameId?: string;
|
||||||
|
gameNumber: number;
|
||||||
|
gameLabel?: string; // CS2 map name, etc.
|
||||||
|
team1Score: number | null;
|
||||||
|
team2Score: number | null;
|
||||||
|
winnerExternalId?: string | null;
|
||||||
|
status: "scheduled" | "in_progress" | "complete";
|
||||||
|
}
|
||||||
|
interface MatchSyncAdapter {
|
||||||
|
fetchMatches(externalSeasonId: string): Promise<MatchRecord[]>;
|
||||||
|
supportsLiveScores: boolean;
|
||||||
|
}
|
||||||
|
interface MatchSyncResult {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
unchanged: number;
|
||||||
|
unmatchedTeams: {externalId: string; name: string}[];
|
||||||
|
errors: {externalMatchId: string; error: string}[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`pandascore.ts`** — `PandaScoreMatchSyncAdapter implements MatchSyncAdapter`. Calls PandaScore CS2 matches endpoint. Maps stage/round/game (map) fields.
|
||||||
|
|
||||||
|
**`espn-schedule.ts`** — `EspnScheduleAdapter implements MatchSyncAdapter` (Phase 2b). Calls ESPN schedule endpoints for MLB, NBA, MLS, etc. `team1Score`/`team2Score` from ESPN scores.
|
||||||
|
|
||||||
|
**`index.ts`** — `syncMatches(sportsSeasonId)` orchestrator, mirrors `syncStandings()` exactly:
|
||||||
|
1. Load sportsSeason + sport, extract `externalSeasonId`
|
||||||
|
2. `getMatchSyncAdapter(simulatorType)` → picks adapter
|
||||||
|
3. Fetch matches, resolve participants via `externalId` → name fallback
|
||||||
|
4. Bulk upsert into `season_matches` + `match_sub_games`
|
||||||
|
5. Return `MatchSyncResult`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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");
|
||||||
|
// ... expand as needed
|
||||||
|
default: throw new Error(`No match sync adapter for "${simulatorType}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2b. New cron job: `app/routes/admin/jobs.sync-matches.ts`
|
||||||
|
Exact same pattern as `jobs.sync-and-simulate.ts`:
|
||||||
|
- `POST /admin/jobs/sync-matches` with `requireCronSecret` auth
|
||||||
|
- Finds active sports seasons where `externalSeasonId IS NOT NULL`
|
||||||
|
- Calls `syncMatches(season.id)` for each; triggers simulation if results changed
|
||||||
|
- Returns `{ synced, unchanged, errors }`
|
||||||
|
|
||||||
|
Register in `app/routes.ts`.
|
||||||
|
|
||||||
|
### 2c. Admin form: add `external_season_id` field to sports season admin form
|
||||||
|
|
||||||
|
### 2d. Tests
|
||||||
|
- `app/services/match-sync/__tests__/pandascore.test.ts`
|
||||||
|
- `app/services/match-sync/__tests__/espn-schedule.test.ts`
|
||||||
|
- `app/services/match-sync/__tests__/sync.test.ts` — idempotency, name matching, null stage/round handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Public Tournament & Schedule Display + Live Scores
|
||||||
|
|
||||||
|
### 3a. CS2 Tournament component: `app/components/scoring/Cs2TournamentBracket.tsx`
|
||||||
|
Tab layout: **Swiss Stages** | **Champions Stage**
|
||||||
|
- Swiss Stages tab: sub-tabs Stage 1 / 2 / 3. Each shows W-L standings table + rounds accordion. Each round: pairing, map scores, status badge.
|
||||||
|
- Champions Stage tab: reuses existing `<PlayoffBracket />` with `bracketTemplateId="simple_8"`.
|
||||||
|
- Status badges: `scheduled` (gray), `in_progress` (amber pulse), `complete` (green).
|
||||||
|
|
||||||
|
### 3b. Generic schedule component: `app/components/scoring/MatchSchedule.tsx`
|
||||||
|
Reusable across all sports. Shows upcoming + completed matches with scores. Used for MLB schedule view, EPL fixtures, etc. Props: `matches: SeasonMatch[]`, `sport`.
|
||||||
|
|
||||||
|
### 3c. New public route: `app/routes/sports-seasons.$sportsSeasonId.tournament.tsx`
|
||||||
|
URL: `/sports-seasons/:sportsSeasonId/tournament` — league-independent.
|
||||||
|
Loader: fetches `season_matches` + playoff matches (Champions Stage) + cs2 stage results.
|
||||||
|
CS2 renders `<Cs2TournamentBracket>`, other sports render `<MatchSchedule>` based on `simulatorType`.
|
||||||
|
Live polling: if any match `in_progress`, `useRevalidator()` re-fetches every 30 seconds.
|
||||||
|
|
||||||
|
Register in `app/routes.ts` outside admin layout.
|
||||||
|
|
||||||
|
### 3d. Optional: Socket.IO push
|
||||||
|
Emit `season-match-updated` from `syncMatches` when a match transitions to `in_progress` or `complete`. Phase 3 enhancement, deferred until polling is stable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Automated Playoff Bracket Updates (future)
|
||||||
|
|
||||||
|
Playoff bracket matches (`playoff_matches`) can also be auto-synced using the same `MatchSyncAdapter` interface. This is deferred because playoff sync is more complex than schedule sync — it must trigger `processMatchResult()` and bracket progression logic correctly, not just upsert data.
|
||||||
|
|
||||||
|
**What's needed to enable this:**
|
||||||
|
1. Add `external_match_id varchar(255) nullable` column to `playoff_matches` (one migration)
|
||||||
|
2. Add `fetchPlayoffMatches(externalSeasonId)` to the adapter (or reuse `fetchMatches` with a `matchType` flag)
|
||||||
|
3. Add a `syncPlayoffResults(sportsSeasonId)` path in `match-sync/index.ts` that writes to `playoff_matches` and calls the existing `setMatchWinner()` / `processMatchResult()` pipeline
|
||||||
|
4. Handle idempotency — don't re-trigger scoring if winner is already set
|
||||||
|
|
||||||
|
The same `PandaScoreMatchSyncAdapter` and `EspnScheduleAdapter` used for schedule sync will handle playoff data too, since they already return match results regardless of format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Does NOT Change (Phases 1–3)
|
||||||
|
- `cs2_major_stage_results` — still the source of truth for CS2 stage entry/exit/placement
|
||||||
|
- Fantasy scoring pipeline — fully untouched
|
||||||
|
- `playoff_matches` / `playoff_match_games` — unchanged, still used for all bracket progression
|
||||||
|
- Existing CS2 admin setup UI — works as before, just gains a new read-only section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `database/schema.ts` | Add `matchStatusEnum`, `season_matches`, `match_sub_games` tables, `externalSeasonId` on sportsSeasons |
|
||||||
|
| `app/models/season-match.ts` | New — all DB queries for new tables |
|
||||||
|
| `app/services/match-sync/types.ts` | New — generic adapter interface |
|
||||||
|
| `app/services/match-sync/pandascore.ts` | New — PandaScore CS2 implementation |
|
||||||
|
| `app/services/match-sync/espn-schedule.ts` | New — ESPN schedule implementation (MLB etc.) |
|
||||||
|
| `app/services/match-sync/index.ts` | New — orchestrator (mirrors standings-sync/index.ts) |
|
||||||
|
| `app/routes/admin/jobs.sync-matches.ts` | New — cron job (mirrors jobs.sync-and-simulate.ts) |
|
||||||
|
| `app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx` | Extend loader + Swiss rounds display section |
|
||||||
|
| `app/routes/admin.sports-seasons.$id.events.$eventId.swiss-matches.tsx` | New — manual match entry admin route |
|
||||||
|
| `app/components/scoring/Cs2TournamentBracket.tsx` | New — CS2 tournament display |
|
||||||
|
| `app/components/scoring/MatchSchedule.tsx` | New — generic match schedule display |
|
||||||
|
| `app/routes/sports-seasons.$sportsSeasonId.tournament.tsx` | New — public tournament/schedule route |
|
||||||
|
| `app/routes.ts` | Register 3 new routes |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. `npm run db:generate` produces migration for 2 new tables + column
|
||||||
|
2. `npm run db:migrate` applies cleanly
|
||||||
|
3. `npm run typecheck` passes
|
||||||
|
4. `npm run test:run` passes including new model + adapter tests
|
||||||
|
5. Manual: set `externalSeasonId` on a CS2 sports season, call `/admin/jobs/sync-matches`, verify `season_matches` rows created with stage/round populated
|
||||||
|
6. Manual: navigate to `/sports-seasons/:id/tournament`, confirm CS2 Swiss stage display renders with rounds and map scores
|
||||||
87
drizzle/0120_tidy_invaders.sql
Normal file
87
drizzle/0120_tidy_invaders.sql
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
CREATE TYPE "public"."match_status" AS ENUM('scheduled', 'in_progress', 'complete', 'canceled', 'postponed');--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "match_sub_games" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"season_match_id" uuid NOT NULL,
|
||||||
|
"game_number" integer NOT NULL,
|
||||||
|
"game_label" varchar(100),
|
||||||
|
"participant1_score" integer,
|
||||||
|
"participant2_score" integer,
|
||||||
|
"winner_id" uuid,
|
||||||
|
"status" "match_status" DEFAULT 'scheduled' NOT NULL,
|
||||||
|
"started_at" timestamp,
|
||||||
|
"completed_at" timestamp,
|
||||||
|
"external_game_id" varchar(255),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "season_matches" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"sports_season_id" uuid NOT NULL,
|
||||||
|
"scoring_event_id" uuid,
|
||||||
|
"participant1_id" uuid,
|
||||||
|
"participant2_id" uuid,
|
||||||
|
"winner_id" uuid,
|
||||||
|
"participant1_score" integer,
|
||||||
|
"participant2_score" integer,
|
||||||
|
"match_stage" integer,
|
||||||
|
"match_round" integer,
|
||||||
|
"matchday" integer,
|
||||||
|
"is_series" boolean DEFAULT false NOT NULL,
|
||||||
|
"status" "match_status" DEFAULT 'scheduled' NOT NULL,
|
||||||
|
"scheduled_at" timestamp,
|
||||||
|
"started_at" timestamp,
|
||||||
|
"completed_at" timestamp,
|
||||||
|
"external_match_id" varchar(255),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "sports_seasons" ADD COLUMN "external_season_id" varchar(255);--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "match_sub_games" ADD CONSTRAINT "match_sub_games_season_match_id_season_matches_id_fk" FOREIGN KEY ("season_match_id") REFERENCES "public"."season_matches"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "match_sub_games" ADD CONSTRAINT "match_sub_games_winner_id_season_participants_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_sports_season_id_sports_seasons_id_fk" FOREIGN KEY ("sports_season_id") REFERENCES "public"."sports_seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_scoring_event_id_scoring_events_id_fk" FOREIGN KEY ("scoring_event_id") REFERENCES "public"."scoring_events"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_participant1_id_season_participants_id_fk" FOREIGN KEY ("participant1_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_participant2_id_season_participants_id_fk" FOREIGN KEY ("participant2_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "season_matches" ADD CONSTRAINT "season_matches_winner_id_season_participants_id_fk" FOREIGN KEY ("winner_id") REFERENCES "public"."season_participants"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "match_sub_games_match_game_number_unique" ON "match_sub_games" USING btree ("season_match_id","game_number");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "season_matches_external_id_unique" ON "season_matches" USING btree ("external_match_id") WHERE "season_matches"."external_match_id" IS NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "season_matches_sports_season_status_idx" ON "season_matches" USING btree ("sports_season_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "season_matches_sports_season_stage_round_idx" ON "season_matches" USING btree ("sports_season_id","match_stage","match_round");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "season_matches_scoring_event_idx" ON "season_matches" USING btree ("scoring_event_id");
|
||||||
1
drizzle/0121_even_gambit.sql
Normal file
1
drizzle/0121_even_gambit.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "playoff_matches" ADD COLUMN "external_match_id" varchar(255);
|
||||||
6712
drizzle/meta/0120_snapshot.json
Normal file
6712
drizzle/meta/0120_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
6718
drizzle/meta/0121_snapshot.json
Normal file
6718
drizzle/meta/0121_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -841,6 +841,20 @@
|
||||||
"when": 1781031439603,
|
"when": 1781031439603,
|
||||||
"tag": "0119_eminent_siren",
|
"tag": "0119_eminent_siren",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 120,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781294866660,
|
||||||
|
"tag": "0120_tidy_invaders",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 121,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781296473277,
|
||||||
|
"tag": "0121_even_gambit",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue