Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m13s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.

Core
- match-sync: WikipediaTennisAdapter + wikitext bracket parser, DrawSync
  DTOs, syncTennisDraw orchestrator, pure draw->rows mapping
- playoff-match: populateBracketFromDraw (idempotent upsert on externalMatchId)
- scoring_events.externalSourceKey column (Wikipedia article; migration 0123)
- admin bracket "Sync Draw" card (accepts URL or title) + cron pass

Scoring fix
- deriveBracketQualifyingStates only floors players who have reached the
  scoring stage; for deep brackets (tennis_128) early-round losers earn 0 QP
  instead of a phantom 9th-place floor. CS2/simple_8 behavior preserved
  (gated on rounds[0].isScoring). Re-sync reconciles stale QP rows in a
  transaction.

Matching & parsing
- accent-folding in normalizeTeamName; strip Wikipedia "(tennis)"
  disambiguators; treat Bye/TBD/Qualifier as TBD; extract wikilink before
  template-stripping so {{nowrap}}-wrapped players parse

Admin UX
- dry-run preview (matched / will-create / possible duplicates / unfilled
  slots) with inline "rename existing" / "create as new" resolution via
  fetcher (no full reload)

Tests: parser vs real 2025 Wimbledon fixture, draw mapping, tennis_128
scoring, accent/disambiguator/nowrap parsing, URL parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-26 00:32:35 -07:00
parent 120056b0bd
commit 81d063faa1
20 changed files with 10616 additions and 15 deletions

View file

@ -14,6 +14,11 @@ describe("normalizeTeamName", () => {
const name = "oklahoma city thunder";
expect(normalizeTeamName(name)).toBe(name);
});
it("folds accents so diacritics don't block a match", () => {
expect(normalizeTeamName("Stéfanos Tsitsipás")).toBe("stefanos tsitsipas");
expect(normalizeTeamName("Félix Auger-Aliassime")).toBe("felix auger-aliassime");
});
});
describe("findMatchingTeamName", () => {
@ -38,6 +43,12 @@ describe("findMatchingTeamName", () => {
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder");
});
it("matches across accents (API has them, our list doesn't)", () => {
expect(findMatchingTeamName("Stéfanos Tsitsipás", ["Stefanos Tsitsipas"])).toBe(
"Stefanos Tsitsipas",
);
});
it("returns null for unmatched name", () => {
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
});

View file

@ -1,9 +1,15 @@
/**
* Normalize a team name for fuzzy matching across data sources.
* Lowercases, trims, and collapses whitespace.
* Normalize a team/participant name for fuzzy matching across data sources.
* Folds accents (so "Stéfanos Tsitsipás" matches a plain "Stefanos Tsitsipas"),
* lowercases, trims, and collapses whitespace.
*/
export function normalizeTeamName(name: string): string {
return name.toLowerCase().trim().replace(/\s+/g, " ");
return name
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "") // strip combining diacritical marks
.toLowerCase()
.trim()
.replace(/\s+/g, " ");
}
/**

View file

@ -146,6 +146,57 @@ describe("deriveBracketQualifyingStates (simple_8)", () => {
});
});
// ── tennis_128: scoring starts deep (Round of 16), not at round 1 ─────────────
describe("deriveBracketQualifyingStates (tennis_128 — deep scoring start)", () => {
const TENNIS = BRACKET_TEMPLATES["tennis_128"];
const tStates = (matches: BracketMatchInput[]) =>
deriveBracketQualifyingStates(matches, TENNIS.rounds, (round) =>
getRoundConfig(round, "tennis_128"),
);
it("awards NO QP to a player who lost before the Round of 16", () => {
const s = tStates([
played("Round of 128", "winner", "earlyLoser"),
]);
expect(s.has("earlyLoser")).toBe(false); // 0 QP — didn't reach scoring
});
it("awards NO QP just for winning a pre-scoring round (still short of R16)", () => {
// Won R128, next match (R64) not played → not yet in the Round of 16.
const s = tStates([played("Round of 128", "p", "x")]);
expect(s.has("p")).toBe(false);
});
it("gives NO entry floor to undrawn/unplayed players (the 9th-place bug)", () => {
const s = tStates([
pending("Round of 128", "a", "b"),
pending("Round of 128", "c", "d"),
]);
expect(s.size).toBe(0); // nobody has earned anything yet
});
it("floors a player who reached the Round of 16 (won R32) at T916", () => {
// a wins R128 → R64 → R32, so a is now IN the Round of 16 (not yet played).
const s = tStates([
played("Round of 128", "a", "x1"),
played("Round of 64", "a", "x2"),
played("Round of 32", "a", "x3"),
]);
expect(s.get("a")).toEqual({ placement: 9, tieCount: 8 });
// The players a beat in non-scoring rounds earn nothing.
expect(s.has("x1")).toBe(false);
});
it("locks an actual Round of 16 loser at T916 and floors the winner at T58", () => {
const s = tStates([
played("Round of 16", "r16winner", "r16loser"),
]);
expect(s.get("r16loser")).toEqual({ placement: 9, tieCount: 8 });
expect(s.get("r16winner")).toEqual({ placement: 5, tieCount: 4 }); // QF floor
});
});
// ── QP values (the worked example) ────────────────────────────────────────────
describe("guaranteed-minimum QP per outcome (default config)", () => {

View file

@ -142,6 +142,96 @@ export async function setMatchWinner(
return match;
}
/** A draw match with participants/winner already resolved to ids. */
export interface ResolvedDrawMatch {
externalMatchId: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isScoring: boolean;
}
/**
* Populate (or advance) a bracket from a fully-resolved external draw, keyed on
* `externalMatchId` so repeated syncs update in place rather than duplicating.
*
* Unlike `generateBracketFromTemplate` (which seeds only round 1 and leaves later
* rounds empty for `advanceWinnerTemplate`), this writes every round's actual
* matchups and known winners directly from the source appropriate for a feed
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
* is marked complete when both its winner and loser are known.
*
* @returns counts of rows written (inserted or updated) and those carrying a result.
*/
export async function populateBracketFromDraw(
eventId: string,
matches: ResolvedDrawMatch[]
): Promise<{ written: number; completed: number }> {
const db = database();
const existing = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
});
const byExternalId = new Map(
existing
.filter((m) => m.externalMatchId)
.map((m) => [m.externalMatchId as string, m])
);
const toInsert: NewPlayoffMatch[] = [];
let written = 0;
let completed = 0;
for (const m of matches) {
const isComplete = m.winnerId !== null && m.loserId !== null;
if (isComplete) completed++;
const existingRow = byExternalId.get(m.externalMatchId);
if (existingRow) {
await db
.update(schema.playoffMatches)
.set({
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
loserId: m.loserId,
isComplete,
isScoring: m.isScoring,
templateRound: m.round,
updatedAt: new Date(),
})
.where(eq(schema.playoffMatches.id, existingRow.id));
written++;
} else {
toInsert.push({
scoringEventId: eventId,
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
loserId: m.loserId,
isComplete,
isScoring: m.isScoring,
templateRound: m.round,
externalMatchId: m.externalMatchId,
});
}
}
if (toInsert.length > 0) {
await createManyPlayoffMatches(toInsert);
written += toInsert.length;
}
return { written, completed };
}
/**
* Generate a standard single elimination bracket structure
*/

View file

@ -727,15 +727,38 @@ export function deriveBracketQualifyingStates(
}
if (!highest) {
// In the bracket but no match resolved yet → entry floor.
const cfg = firstScoringRound ? getConfig(firstScoringRound.name) : null;
// In the bracket but no match resolved yet → sitting in the first round.
// Only grant an entry floor if that first round is itself a scoring round
// (e.g. simple_8 / CS2 Champions Stage, where every entrant is already in
// the scoring stage). For deep brackets whose scoring starts later
// (tennis_128: Round of 128 → … → Round of 16), merely being drawn earns
// nothing — no QP until the player reaches the first scoring round.
if (!rounds[0]?.isScoring || !firstScoringRound) continue;
const cfg = getConfig(firstScoringRound.name);
if (!cfg) continue;
result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount });
continue;
}
const cfg = getConfig(highest);
if (!cfg) continue;
if (!cfg) {
// Won a non-scoring round. If that win advanced them INTO a scoring round,
// they've guaranteed that round's loser floor (e.g. a tennis R32 win → in
// the Round of 16 → guaranteed T916). Otherwise (won an earlier
// non-scoring round) they've earned no QP yet.
const feedsInto = feedsIntoByRound.get(highest) ?? null;
const nextRound = feedsInto ? rounds.find((r) => r.name === feedsInto) : null;
if (nextRound?.isScoring) {
const floorCfg = getConfig(nextRound.name);
if (floorCfg) {
result.set(id, {
placement: floorCfg.loserPosition,
tieCount: nextRound.matchCount,
});
}
}
continue;
}
if (cfg.winnerFloor === null) {
// Won the finalization round → champion (or 3rd-place-game winner).
result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 });

View file

@ -42,6 +42,8 @@ export interface UpdateScoringEventData {
scoringStartsAtRound?: string;
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
bracketRegionConfig?: BracketRegion[] | null;
/** External data-source locator (e.g. Wikipedia article title for tennis draws) */
externalSourceKey?: string | null;
}
/**
@ -173,6 +175,7 @@ export async function updateScoringEvent(
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
if (data.externalSourceKey !== undefined) updateData.externalSourceKey = data.externalSourceKey;
const [updated] = await db
.update(schema.scoringEvents)

View file

@ -1,7 +1,11 @@
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { findSportsSeasonById } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
import {
findParticipantsBySportsSeasonId,
createParticipant,
updateParticipant,
} from "~/models/season-participant";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import {
findPlayoffMatchesByEventId,
@ -62,6 +66,8 @@ import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id);
@ -159,6 +165,72 @@ export async function action({ request, params }: Route.ActionArgs) {
}
}
if (intent === "create-draw-participant") {
const name = (formData.get("name") as string | null)?.trim();
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
if (!name) return { error: "Participant name is required" };
try {
await createParticipant({ sportsSeasonId: params.id, name, externalId });
return { success: `Created "${name}". Re-run Preview or Sync Draw to apply.` };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to create participant" };
}
}
if (intent === "relink-draw-participant") {
const participantId = formData.get("participantId") as string | null;
const name = (formData.get("name") as string | null)?.trim();
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
if (!participantId || !name) return { error: "Participant and name are required" };
try {
await updateParticipant(participantId, { name, externalId });
return { success: `Renamed and linked to "${name}". Re-run Preview or Sync Draw to apply.` };
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to update participant" };
}
}
if (intent === "preview-draw") {
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
// Persist the article so the user can preview, then sync, without re-entering.
if (articleTitle) {
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
}
try {
const preview = await previewTennisDraw(params.eventId);
return { drawPreview: preview };
} catch (error) {
logger.error("[preview-draw] error:", error);
return { error: error instanceof Error ? error.message : "Failed to preview draw" };
}
}
if (intent === "sync-draw") {
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
// Accept a pasted Wikipedia URL or a plain article title; store the title.
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
if (articleTitle) {
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
}
try {
const result = await syncTennisDraw(params.eventId);
const reviewNote =
result.unmatched.length > 0
? ` ${result.unmatched.length} auto-created player(s) need a quick review for possible duplicates.`
: "";
return {
success:
`Synced draw: ${result.matchesWritten} matches written ` +
`(${result.completed} completed), ${result.participantsCreated} participant(s) added.${reviewNote}`,
drawSyncResult: result,
};
} catch (error) {
logger.error("[sync-draw] error:", error);
return { error: error instanceof Error ? error.message : "Failed to sync draw" };
}
}
if (intent === "generate-bracket") {
const templateId = formData.get("templateId");

View file

@ -1,4 +1,4 @@
import { Form, Link } from "react-router";
import { Form, Link, useFetcher } from "react-router";
import { Fragment, useState, useEffect, useMemo, useRef } from "react";
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
@ -93,6 +93,62 @@ function GroupMatchScheduleForm({
export { loader, action };
/**
* One "possible duplicate" row in the draw preview. Uses a fetcher so resolving
* it (rename existing / create as new) submits in the background the preview
* stays on screen instead of reloading the page and forcing a fresh dry run.
*/
function DuplicateRow({
p,
}: {
p: { name: string; externalId: string | null; suggestion: string; suggestionId: string };
}) {
const fetcher = useFetcher<{ success?: string; error?: string }>();
const busy = fetcher.state !== "idle";
return (
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>
<span className="font-medium">{p.name}</span>
<span className="text-muted-foreground"> looks like </span>
<span className="font-medium">{p.suggestion}</span>
</span>
{fetcher.data?.success ? (
<span className="text-xs font-medium text-emerald-500"> {fetcher.data.success}</span>
) : (
<fetcher.Form method="post" className="flex items-center gap-2">
<input type="hidden" name="name" value={p.name} />
<input type="hidden" name="externalId" value={p.externalId ?? ""} />
<input type="hidden" name="participantId" value={p.suggestionId} />
<Button
type="submit"
name="intent"
value="relink-draw-participant"
size="sm"
variant="outline"
disabled={busy}
>
Rename {p.suggestion} {p.name}
</Button>
<Button
type="submit"
name="intent"
value="create-draw-participant"
size="sm"
variant="outline"
disabled={busy}
>
Create as new
</Button>
</fetcher.Form>
)}
{fetcher.data?.error && (
<span className="text-xs text-destructive">{fetcher.data.error}</span>
)}
</li>
);
}
export default function EventBracket({
loaderData,
actionData,
@ -375,6 +431,164 @@ export default function EventBracket({
</div>
)}
{/* Possible-duplicate review after a draw sync */}
{actionData?.drawSyncResult && actionData.drawSyncResult.unmatched.length > 0 && (
<Card className="border-amber-500/40">
<CardHeader>
<CardTitle className="text-amber-500">
Review {actionData.drawSyncResult.unmatched.length} possible duplicate
{actionData.drawSyncResult.unmatched.length === 1 ? "" : "s"}
</CardTitle>
<CardDescription>
These players were auto-created but closely resemble an existing participant
if a duplicate, results won&apos;t reach the drafted copy. To fix: on the{" "}
<Link
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
className="underline font-medium"
>
participants page
</Link>
, set the correct participant&apos;s external ID to the Wikipedia name, delete the
duplicate, then click <span className="font-medium">Sync Draw</span> again.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
{actionData.drawSyncResult.unmatched.map((p) => (
<li key={p.externalId ?? p.name} className="flex items-center gap-2">
<span className="font-medium">{p.name}</span>
{p.externalId && p.externalId !== p.name && (
<span className="text-muted-foreground text-xs">({p.externalId})</span>
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Dry-run preview of a draw sync */}
{actionData?.drawPreview && (
<Card className="border-sky-500/40">
<CardHeader>
<CardTitle className="text-sky-500">Draw preview (no changes made)</CardTitle>
<CardDescription>{actionData.drawPreview.article}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-3">
<div>
Players: <span className="font-medium">{actionData.drawPreview.totalPlayers}</span>
</div>
<div>
Matched:{" "}
<span className="font-medium text-emerald-500">
{actionData.drawPreview.matched}
</span>
</div>
<div>
Will create:{" "}
<span className="font-medium">{actionData.drawPreview.willCreate.length}</span>
</div>
<div>
Matches:{" "}
<span className="font-medium">
{actionData.drawPreview.completedMatches}/{actionData.drawPreview.totalMatches}{" "}
done
</span>
</div>
<div>
Unfilled R1 slots:{" "}
<span className="font-medium">{actionData.drawPreview.tbdFirstRound}</span>
</div>
</div>
{actionData.drawPreview.possibleDuplicates.length > 0 && (
<div>
<p className="font-medium text-amber-500">
Possible duplicates ({actionData.drawPreview.possibleDuplicates.length}) would
be created but resemble an existing participant:
</p>
<ul className="mt-2 space-y-2">
{actionData.drawPreview.possibleDuplicates.map((p) => (
<DuplicateRow key={p.externalId ?? p.name} p={p} />
))}
</ul>
<p className="mt-2 text-xs text-muted-foreground">
<span className="font-medium">Rename</span> if it&apos;s the same player (links
the existing participant so it matches);{" "}
<span className="font-medium">Create as new</span> if they&apos;re different
people. Then re-run Preview or Sync Draw.
</p>
</div>
)}
{actionData.drawPreview.willCreate.length > 0 && (
<details>
<summary className="cursor-pointer text-muted-foreground">
Show all {actionData.drawPreview.willCreate.length} players that would be created
</summary>
<ul className="mt-1 columns-2 sm:columns-3">
{actionData.drawPreview.willCreate.map((p) => (
<li key={p.externalId ?? p.name}>{p.name}</li>
))}
</ul>
</details>
)}
<p className="text-xs text-muted-foreground">
Fix any duplicates on the{" "}
<Link
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
className="underline font-medium"
>
participants page
</Link>{" "}
first, then click <span className="font-medium">Sync Draw</span>.
</p>
</CardContent>
</Card>
)}
{/* Sync Draw from Wikipedia — tennis Grand Slam auto-populate + auto-score */}
{event.isQualifyingEvent &&
sportsSeason.sport.simulatorType === "tennis_qualifying_points" && (
<Card>
<CardHeader>
<CardTitle>Sync Draw from Wikipedia</CardTitle>
<CardDescription>
Auto-populate and score this Grand Slam bracket from its Wikipedia draw
article. Re-run during the tournament to pull in completed matches.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="externalSourceKey">Wikipedia draw page (URL or title)</Label>
<Input
id="externalSourceKey"
name="externalSourceKey"
placeholder="https://en.wikipedia.org/wiki/2025_Wimbledon_Championships__Men's_singles"
defaultValue={event.externalSourceKey ?? ""}
/>
<p className="text-xs text-muted-foreground">
Paste the article URL or type the title both work. Use{" "}
<span className="font-medium">Preview</span> first to see matches and any
new/duplicate players before writing anything.
</p>
</div>
<div className="flex gap-2">
<Button type="submit" name="intent" value="preview-draw" variant="outline">
Preview (dry run)
</Button>
<Button type="submit" name="intent" value="sync-draw">
Sync Draw
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
{/* Reprocess Bracket - Full rebuild of participant results.
Hidden once every match is complete at that point all placements are final
and "finalize bracket" should be used instead. */}

View file

@ -2,7 +2,7 @@ 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";
import { syncMatches, syncTennisDraw } from "~/services/match-sync";
export async function action({ request }: { request: Request }) {
requireCronSecret(request);
@ -37,5 +37,39 @@ export async function action({ request }: { request: Request }) {
}
}
return Response.json({ synced, errors }, { status: errors.length > 0 && synced.length === 0 ? 500 : 200 });
// Tennis Grand Slam draws: sync each active tennis major's primary window from
// its configured Wikipedia article. (Tennis uses a per-event externalSourceKey,
// not a per-season externalSeasonId, so it isn't covered by the loop above.)
const tennisEvents = await db.query.scoringEvents.findMany({
where: and(
isNotNull(schema.scoringEvents.externalSourceKey),
eq(schema.scoringEvents.isQualifyingEvent, true),
eq(schema.scoringEvents.isComplete, false),
),
with: { sportsSeason: { with: { sport: true } } },
});
const drawSynced: string[] = [];
for (const ev of tennisEvents) {
if (ev.sportsSeason?.status !== "active") continue;
if (ev.sportsSeason?.sport?.simulatorType !== "tennis_qualifying_points") continue;
// Skip read-only siblings of a shared major — results fan out from the primary.
if (ev.tournamentId && !ev.isPrimary) continue;
try {
await syncTennisDraw(ev.id);
drawSynced.push(ev.id);
} catch (err) {
errors.push({
id: ev.id,
name: ev.name ?? "(tennis draw)",
error: err instanceof Error ? err.message : String(err),
});
}
}
const okCount = synced.length + drawSynced.length;
return Response.json(
{ synced, drawSynced, errors },
{ status: errors.length > 0 && okCount === 0 ? 500 : 200 },
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,147 @@
import { describe, it, expect } from "vitest";
import {
playerKey,
canonicalPlayerName,
buildResolvedMatches,
} from "../tennis-draw-mapping";
import type { FetchedDraw, FetchedPlayer } from "../types";
const sinner: FetchedPlayer = { name: "J Sinner", externalId: "Jannik Sinner", seed: 1 };
const djokovic: FetchedPlayer = { name: "N Djokovic", externalId: "Novak Djokovic", seed: 6 };
const noLink: FetchedPlayer = { name: "Some Qualifier", externalId: null, seed: null };
describe("playerKey", () => {
it("prefers the external id", () => {
expect(playerKey(sinner)).toBe("id:Jannik Sinner");
});
it("falls back to a normalized name when there is no external id", () => {
expect(playerKey(noLink)).toBe("name:some qualifier");
});
});
describe("canonicalPlayerName", () => {
it("uses the full name from the external id, not the abbreviated display", () => {
expect(canonicalPlayerName(sinner)).toBe("Jannik Sinner");
});
it("uses the display name when there is no external id", () => {
expect(canonicalPlayerName(noLink)).toBe("Some Qualifier");
});
it("strips the trailing Wikipedia disambiguator", () => {
expect(
canonicalPlayerName({ name: "Arthur Fils", externalId: "Arthur Fils (tennis)", seed: null }),
).toBe("Arthur Fils");
});
});
describe("buildResolvedMatches", () => {
const draw: FetchedDraw = {
templateId: "tennis_128",
rounds: [
{
roundName: "Round of 128",
matches: [
{
externalMatchId: "m-r128-1",
round: "Round of 128",
matchNumber: 1,
player1: sinner,
player2: noLink,
winner: 1,
},
],
},
{
roundName: "Final",
matches: [
{
externalMatchId: "m-final",
round: "Final",
matchNumber: 1,
player1: djokovic,
player2: sinner,
winner: 2,
},
],
},
],
};
const idByKey = new Map<string, string>([
[playerKey(sinner), "sp-sinner"],
[playerKey(djokovic), "sp-djokovic"],
[playerKey(noLink), "sp-qual"],
]);
const roundIsScoring = new Map<string, boolean>([
["Round of 128", false],
["Final", true],
]);
const resolved = buildResolvedMatches(draw, idByKey, roundIsScoring);
it("maps winner slot 1 to winnerId/loserId", () => {
const r128 = resolved.find((m) => m.externalMatchId === "m-r128-1");
expect(r128?.winnerId).toBe("sp-sinner");
expect(r128?.loserId).toBe("sp-qual");
expect(r128?.isScoring).toBe(false);
});
it("maps winner slot 2 to the second player", () => {
const final = resolved.find((m) => m.externalMatchId === "m-final");
expect(final?.participant1Id).toBe("sp-djokovic");
expect(final?.participant2Id).toBe("sp-sinner");
expect(final?.winnerId).toBe("sp-sinner");
expect(final?.loserId).toBe("sp-djokovic");
expect(final?.isScoring).toBe(true);
});
it("leaves winner/loser null when the source reported no winner", () => {
const noWinnerDraw: FetchedDraw = {
templateId: "tennis_128",
rounds: [
{
roundName: "Round of 128",
matches: [
{
externalMatchId: "m-x",
round: "Round of 128",
matchNumber: 1,
player1: sinner,
player2: djokovic,
winner: null,
},
],
},
],
};
const [m] = buildResolvedMatches(noWinnerDraw, idByKey, roundIsScoring);
expect(m.winnerId).toBeNull();
expect(m.loserId).toBeNull();
});
it("defaults isScoring to false for an unknown round", () => {
const [m] = buildResolvedMatches(
{
templateId: "tennis_128",
rounds: [
{
roundName: "Mystery",
matches: [
{
externalMatchId: "m-y",
round: "Mystery",
matchNumber: 1,
player1: sinner,
player2: djokovic,
winner: 1,
},
],
},
],
},
idByKey,
new Map(),
);
expect(m.isScoring).toBe(false);
});
});

View file

@ -0,0 +1,176 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
parseTennisDrawWikitext,
parsePlayerCell,
parseTemplateParams,
extractTemplates,
slugifyArticle,
articleTitleFromInput,
} from "../wikipedia-tennis";
import type { FetchedRound } from "../types";
const FIXTURE = readFileSync(
join(__dirname, "fixtures", "wimbledon-2025-mens-singles.wikitext"),
"utf-8",
);
const ARTICLE = "2025 Wimbledon Championships Men's singles";
function roundOrThrow(rounds: FetchedRound[], name: string): FetchedRound {
const round = rounds.find((r) => r.roundName === name);
if (!round) throw new Error(`round ${name} missing`);
return round;
}
describe("parsePlayerCell", () => {
it("extracts name + wikilink target and detects the bolded winner", () => {
const cell = parsePlayerCell("'''{{flagicon|ITA}} [[Jannik Sinner]]'''");
expect(cell).toEqual({
player: { name: "Jannik Sinner", externalId: "Jannik Sinner", seed: null },
isWinner: true,
});
});
it("treats a non-bolded slot as a loser", () => {
const cell = parsePlayerCell("{{flagicon|USA}} [[Ben Shelton]]");
expect(cell?.isWinner).toBe(false);
expect(cell?.player.name).toBe("Ben Shelton");
});
it("handles piped wikilinks (qualifier disambiguation)", () => {
const cell = parsePlayerCell("{{flagicon|FRA}} [[Arthur Fils (tennis)|Arthur Fils]]");
expect(cell?.player.name).toBe("Arthur Fils");
expect(cell?.player.externalId).toBe("Arthur Fils (tennis)");
});
it("parses a player whose cell is wrapped in {{nowrap}} (long names)", () => {
const loser = parsePlayerCell(
"{{nowrap|{{flagicon|ESP}} [[Alejandro Davidovich Fokina|A Davidovich Fokina]]}}",
);
expect(loser?.player.externalId).toBe("Alejandro Davidovich Fokina");
expect(loser?.player.name).toBe("A Davidovich Fokina");
expect(loser?.isWinner).toBe(false);
const winner = parsePlayerCell(
"'''{{nowrap|{{flagicon|ARG}} [[Juan Manuel Cerúndolo|JM Cerúndolo]]}}'''",
);
expect(winner?.player.externalId).toBe("Juan Manuel Cerúndolo");
expect(winner?.isWinner).toBe(true);
});
it("returns null for empty, Bye, and not-yet-determined placeholder slots", () => {
expect(parsePlayerCell("")).toBeNull();
expect(parsePlayerCell("Bye")).toBeNull();
expect(parsePlayerCell("Qualifier")).toBeNull();
expect(parsePlayerCell("{{flagicon|GBR}} Qualifier")).toBeNull();
expect(parsePlayerCell("TBD")).toBeNull();
});
});
describe("parseTemplateParams", () => {
it("splits on top-level pipes only, preserving piped links and templates", () => {
const params = parseTemplateParams(
"RD1-team1={{flagicon|ITA}} [[A B|A]]|RD1-seed1=1|RD1-score1-1=6",
);
expect(params.get("RD1-team1")).toBe("{{flagicon|ITA}} [[A B|A]]");
expect(params.get("RD1-seed1")).toBe("1");
expect(params.get("RD1-score1-1")).toBe("6");
});
});
describe("extractTemplates", () => {
it("finds the 8 section brackets and 1 finals bracket", () => {
expect(extractTemplates(FIXTURE, ["16TeamBracket"]).length).toBe(8);
expect(extractTemplates(FIXTURE, ["8TeamBracket"]).length).toBe(1);
});
});
describe("parseTennisDrawWikitext (2025 Wimbledon men's singles)", () => {
const draw = parseTennisDrawWikitext(FIXTURE, ARTICLE);
it("maps to the tennis_128 template", () => {
expect(draw.templateId).toBe("tennis_128");
});
it("produces the full 127-match draw with correct per-round counts", () => {
const byRound = Object.fromEntries(draw.rounds.map((r) => [r.roundName, r.matches.length]));
expect(byRound).toEqual({
"Round of 128": 64,
"Round of 64": 32,
"Round of 32": 16,
"Round of 16": 8,
Quarterfinals: 4,
Semifinals: 2,
Final: 1,
});
const total = draw.rounds.reduce((sum, r) => sum + r.matches.length, 0);
expect(total).toBe(127);
});
it("populates both players in section rounds (not just the finals bracket)", () => {
const r128 = roundOrThrow(draw.rounds, "Round of 128");
// A full 128 draw has no byes — every R128 slot is filled.
const populated = r128.matches.filter((m) => m.player1 && m.player2);
expect(populated.length).toBe(64);
// Section brackets abbreviate display names ("J Sinner") but the wikilink
// target stays the full name — so externalId is the reliable key.
const sinnerMatch = r128.matches.find(
(m) => m.player1?.externalId === "Jannik Sinner" || m.player2?.externalId === "Jannik Sinner",
);
expect(sinnerMatch).toBeDefined();
const sinner =
sinnerMatch?.player1?.externalId === "Jannik Sinner"
? sinnerMatch?.player1
: sinnerMatch?.player2;
expect(sinner?.name).toBe("J Sinner");
expect(sinner?.seed).toBe(1);
});
it("identifies Sinner as the champion (won the Final)", () => {
const final = roundOrThrow(draw.rounds, "Final").matches[0];
const winner = final.winner === 1 ? final.player1 : final.player2;
expect(winner?.name).toBe("Jannik Sinner");
});
it("gives every match a unique externalMatchId", () => {
const ids = draw.rounds.flatMap((r) => r.matches.map((m) => m.externalMatchId));
expect(new Set(ids).size).toBe(ids.length);
});
it("records seeds for the 32 seeded players in round 1", () => {
const r128 = roundOrThrow(draw.rounds, "Round of 128");
const seeded = r128.matches
.flatMap((m) => [m.player1, m.player2])
.filter((p) => typeof p?.seed === "number");
expect(seeded.length).toBe(32);
});
});
describe("articleTitleFromInput", () => {
it("extracts the title from a pasted Wikipedia URL", () => {
expect(
articleTitleFromInput(
"https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_%E2%80%93_Men%27s_singles",
),
).toBe("2025 Wimbledon Championships Men's singles");
});
it("strips query strings and fragments", () => {
expect(articleTitleFromInput("https://en.wikipedia.org/wiki/French_Open?action=raw#Draw")).toBe(
"French Open",
);
});
it("passes a plain title through unchanged", () => {
expect(articleTitleFromInput(" 2025 US Open Men's singles ")).toBe(
"2025 US Open Men's singles",
);
});
});
describe("slugifyArticle", () => {
it("produces an id-safe slug and strips diacritics", () => {
expect(slugifyArticle(ARTICLE)).toBe("2025-wimbledon-championships-men-s-singles");
});
});

View file

@ -1,15 +1,46 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
import {
findParticipantsBySportsSeasonId,
updateParticipant,
createParticipant,
createManyParticipants,
} 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 {
setMatchWinner,
updatePlayoffMatch,
advanceWinnerTemplate,
findPlayoffMatchesByEventId,
doesLoserAdvance,
populateBracketFromDraw,
} from "~/models/playoff-match";
import {
processMatchResult,
autoCompleteRoundIfDone,
processQualifyingBracketEvent,
recalculateAffectedLeagues,
} from "~/models/scoring-calculator";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results";
import { recalculateParticipantQP } from "~/models/qualifying-points";
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { findMatchingTeamName, normalizeTeamName } from "~/lib/normalize-team-name";
import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation";
import { getBracketTemplate } from "~/lib/bracket-templates";
import { PandaScoreMatchSyncAdapter } from "./pandascore";
import { EspnScheduleAdapter } from "./espn-schedule";
import type { MatchSyncAdapter, MatchSyncResult } from "./types";
import { WikipediaTennisAdapter } from "./wikipedia-tennis";
import { playerKey, canonicalPlayerName, buildResolvedMatches } from "./tennis-draw-mapping";
import type {
MatchSyncAdapter,
MatchSyncResult,
DrawSyncAdapter,
DrawSyncResult,
DrawSyncPreview,
FetchedDraw,
FetchedPlayer,
} from "./types";
import { logger } from "~/lib/logger";
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
@ -269,3 +300,336 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors };
}
// ===========================================================================
// Tennis Grand Slam draw sync
// ===========================================================================
function getDrawAdapter(simulatorType: string): DrawSyncAdapter {
switch (simulatorType) {
case "tennis_qualifying_points":
return new WikipediaTennisAdapter();
default:
throw new Error(
`No draw sync adapter available for simulator type "${simulatorType}". ` +
"Tennis is currently supported.",
);
}
}
/** Validate the event, fetch + parse its draw. Read-only — used by sync & preview. */
async function loadTennisDraw(eventId: string) {
const db = database();
const event = await getScoringEventById(eventId);
if (!event) throw new Error(`Scoring event ${eventId} not found`);
if (isReadOnlySibling(event)) {
throw new Error(
"This event is a read-only window of a shared major. Sync the draw on the primary window; " +
"results fan out here automatically.",
);
}
if (!event.isQualifyingEvent) {
throw new Error(`Event "${event.name}" is not a qualifying event; cannot sync a tennis draw.`);
}
const sourceKey = event.externalSourceKey;
if (!sourceKey) {
throw new Error(
`Event "${event.name}" has no externalSourceKey (Wikipedia article title) configured.`,
);
}
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
with: { sport: true },
});
const simulatorType = sportsSeason?.sport?.simulatorType;
if (!simulatorType) {
throw new Error(`Sport for season ${event.sportsSeasonId} has no simulator type configured`);
}
const draw = await getDrawAdapter(simulatorType).fetchDraw(sourceKey);
const template = getBracketTemplate(draw.templateId);
if (!template) throw new Error(`Bracket template "${draw.templateId}" not found`);
return { event, sportsSeasonId: event.sportsSeasonId, draw, template };
}
/** Distinct players across the whole draw, keyed by stable identity. */
function collectUniquePlayers(draw: FetchedDraw): Map<string, FetchedPlayer> {
const players = new Map<string, FetchedPlayer>();
for (const round of draw.rounds) {
for (const match of round.matches) {
for (const player of [match.player1, match.player2]) {
if (player) players.set(playerKey(player), player);
}
}
}
return players;
}
type SeasonParticipant = Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>[number];
type PlayerLookups = {
byExternalId: Map<string, SeasonParticipant>;
byName: Map<string, SeasonParticipant>;
names: string[];
recon: { id: string; name: string; externalId: string | null }[];
};
function buildLookups(participants: SeasonParticipant[]): PlayerLookups {
return {
byExternalId: new Map(
participants.filter((p) => p.externalId).map((p) => [p.externalId as string, p]),
),
byName: new Map(participants.map((p) => [p.name, p])),
names: participants.map((p) => p.name),
recon: participants.map((p) => ({ id: p.id, name: p.name, externalId: p.externalId })),
};
}
type Classification =
| { kind: "matched"; participant: SeasonParticipant; backfill: boolean }
| {
kind: "create";
name: string;
suggestion: string | null;
suggestionId: string | null;
};
/** Read-only: decide how a drawn player resolves against existing participants. */
function classifyPlayer(player: FetchedPlayer, lk: PlayerLookups): Classification {
const name = canonicalPlayerName(player);
const byId = player.externalId ? lk.byExternalId.get(player.externalId) : undefined;
if (byId) return { kind: "matched", participant: byId, backfill: false };
const matchedName = findMatchingTeamName(name, lk.names);
if (matchedName) {
const p = lk.byName.get(matchedName);
if (p) return { kind: "matched", participant: p, backfill: !p.externalId && !!player.externalId };
}
// No confident match. Surface the closest existing participant (if any) as a
// possible duplicate to review.
const view = buildUnmatchedTeamResolutionView(name, lk.recon);
const top = view.confidence !== "none" ? view.topCandidates[0] ?? null : null;
return {
kind: "create",
name,
suggestion: top?.participantName ?? null,
suggestionId: top?.participantId ?? null,
};
}
/**
* Dry run: report what a draw sync would do (matches, would-be participant
* creations, likely duplicates, unfilled first-round slots) without any writes.
*/
export async function previewTennisDraw(eventId: string): Promise<DrawSyncPreview> {
const { event, sportsSeasonId, draw } = await loadTennisDraw(eventId);
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const lk = buildLookups(participants);
const uniquePlayers = collectUniquePlayers(draw);
let matched = 0;
const willCreate: DrawSyncPreview["willCreate"] = [];
const possibleDuplicates: DrawSyncPreview["possibleDuplicates"] = [];
for (const player of uniquePlayers.values()) {
const c = classifyPlayer(player, lk);
if (c.kind === "matched") {
matched++;
} else {
willCreate.push({ name: c.name, externalId: player.externalId });
if (c.suggestion && c.suggestionId) {
possibleDuplicates.push({
name: c.name,
externalId: player.externalId,
suggestion: c.suggestion,
suggestionId: c.suggestionId,
});
}
}
}
const firstRound = draw.rounds[0];
const tbdFirstRound = firstRound
? firstRound.matches.reduce((n, m) => n + (m.player1 ? 0 : 1) + (m.player2 ? 0 : 1), 0)
: 0;
const allMatches = draw.rounds.flatMap((r) => r.matches);
return {
article: event.externalSourceKey ?? "",
totalPlayers: uniquePlayers.size,
matched,
willCreate,
possibleDuplicates,
tbdFirstRound,
totalMatches: allMatches.length,
completedMatches: allMatches.filter((m) => m.winner !== null).length,
};
}
/**
* Populate and auto-score a tennis major's bracket from its external draw
* (Wikipedia). Resolves every drawn player to a participant matching by
* external id, then name, else auto-creating propagates new participants to
* every sports season linked to the same tournament (so result fan-out lands),
* writes the full bracket, and runs the qualifying-points scorer.
*
* Runs only on a major's primary window; read-only siblings receive results via
* fan-out, not direct scoring.
*/
export async function syncTennisDraw(eventId: string): Promise<DrawSyncResult> {
const db = database();
const { event, sportsSeasonId, draw, template } = await loadTennisDraw(eventId);
const roundIsScoring = new Map(template.rounds.map((r) => [r.name, r.isScoring]));
// ---- Resolve players → participants (primary season) ----------------------
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const lk = buildLookups(participants);
const uniquePlayers = collectUniquePlayers(draw);
const keyToParticipantId = new Map<string, string>();
const resolvedParticipants = new Map<
string,
{ canonicalId: string | null; name: string; externalId: string | null }
>();
const unmatched: DrawSyncResult["unmatched"] = [];
let participantsCreated = 0;
for (const [key, player] of uniquePlayers) {
const c = classifyPlayer(player, lk);
let participant: SeasonParticipant;
if (c.kind === "matched") {
participant = c.participant;
if (c.backfill && player.externalId) {
await updateParticipant(participant.id, { externalId: player.externalId });
}
} else {
// No confident match → auto-create so the bracket is complete; flag a
// likely duplicate for admin review when it resembles an existing player.
if (c.suggestion) unmatched.push({ name: c.name, externalId: player.externalId });
participant = await createParticipant({
sportsSeasonId,
name: c.name,
externalId: player.externalId ?? null,
});
lk.names.push(participant.name);
lk.byName.set(participant.name, participant);
lk.recon.push({ id: participant.id, name: participant.name, externalId: participant.externalId });
if (participant.externalId) lk.byExternalId.set(participant.externalId, participant);
participantsCreated++;
}
keyToParticipantId.set(key, participant.id);
resolvedParticipants.set(key, {
canonicalId: participant.participantId ?? null,
name: participant.name,
externalId: participant.externalId ?? null,
});
}
// ---- Propagate participants to linked sibling seasons ---------------------
if (event.tournamentId) {
const linkedRows = await db
.select({ sportsSeasonId: schema.scoringEvents.sportsSeasonId })
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
const linkedSeasonIds = [...new Set(linkedRows.map((r) => r.sportsSeasonId))].filter(
(id) => id !== sportsSeasonId,
);
// Dedupe by canonical participant: two drawn players can fuzzy-match the same
// existing participant, which would otherwise queue duplicate sibling inserts
// and trip the (sportsSeasonId, name) unique index.
const resolved = [
...new Map(
[...resolvedParticipants.values()]
.filter((r) => r.canonicalId)
.map((r) => [r.canonicalId, r]),
).values(),
];
for (const seasonId of linkedSeasonIds) {
const sib = await findParticipantsBySportsSeasonId(seasonId);
const sibCanon = new Set(sib.map((s) => s.participantId).filter(Boolean));
const sibNames = new Set(sib.map((s) => normalizeTeamName(s.name)));
const toCreate = resolved
.filter(
(r) =>
r.canonicalId &&
!sibCanon.has(r.canonicalId) &&
!sibNames.has(normalizeTeamName(r.name)),
)
.map((r) => ({
sportsSeasonId: seasonId,
name: r.name,
externalId: r.externalId,
participantId: r.canonicalId,
}));
if (toCreate.length > 0) {
await createManyParticipants(toCreate);
participantsCreated += toCreate.length;
}
}
}
// ---- Write the bracket ----------------------------------------------------
const resolvedMatches = buildResolvedMatches(draw, keyToParticipantId, roundIsScoring);
// Ensure the event is configured for QP bracket scoring before populating.
if (
event.bracketTemplateId !== draw.templateId ||
event.scoringStartsAtRound !== template.scoringStartsAtRound
) {
await updateScoringEvent(eventId, {
bracketTemplateId: draw.templateId,
scoringStartsAtRound: template.scoringStartsAtRound,
});
}
const { written, completed } = await populateBracketFromDraw(eventId, resolvedMatches);
// ---- Score (qualifying points) + fan out to siblings ----------------------
// The tennis bracket is the sole QP source for its event, so reconcile stale
// rows: snapshot existing result rows, clear them, re-derive from the bracket,
// then recalc QP totals for any participant who no longer earns points (e.g.
// early-round losers — only Round of 16+ losers score). writeEventResultsQP
// upserts but never deletes, so without this a previously mis-scored player
// would keep phantom QP across re-syncs.
//
// Wrapped in a transaction so a mid-rescore failure can't leave the event with
// its results deleted and not rebuilt. NOTE: this clears ALL event_results for
// the event, including any manually-entered rows (e.g. a notParticipating
// withdrawal) — acceptable because the synced draw is authoritative for tennis.
await db.transaction(async (tx) => {
const beforeRows = await tx
.select({ pid: schema.eventResults.seasonParticipantId })
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
await tx.delete(schema.eventResults).where(eq(schema.eventResults.scoringEventId, eventId));
await processQualifyingBracketEvent(eventId, tx);
const afterRows = await tx
.select({ pid: schema.eventResults.seasonParticipantId })
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, eventId));
const afterIds = new Set(afterRows.map((r) => r.pid));
for (const { pid } of beforeRows) {
if (!afterIds.has(pid)) await recalculateParticipantQP(pid, sportsSeasonId, tx);
}
});
await recalculateAffectedLeagues(sportsSeasonId, db, {
eventId,
eventName: event.name ?? undefined,
});
await fanOutMajorIfPrimary(
{ id: event.id, isPrimary: event.isPrimary, tournamentId: event.tournamentId },
{ markComplete: false },
);
return { matchesWritten: written, completed, participantsCreated, unmatched };
}

View file

@ -0,0 +1,63 @@
/**
* Pure (DB-free) mapping helpers for tennis draw sync, separated so the
* scoring-critical logic winner/loser assignment and which rounds score can
* be unit-tested without a database.
*/
import { normalizeTeamName } from "~/lib/normalize-team-name";
import type { ResolvedDrawMatch } from "~/models/playoff-match";
import type { FetchedDraw, FetchedPlayer } from "./types";
/** Stable identity key for a drawn player (prefer the source's external id). */
export function playerKey(p: FetchedPlayer): string {
return p.externalId ? `id:${p.externalId}` : `name:${normalizeTeamName(p.name)}`;
}
/**
* Canonical participant name for a drawn player. Section brackets abbreviate the
* display name ("J Sinner"); the wikilink target (externalId) is the full name,
* so prefer it. Strips the trailing Wikipedia disambiguator (e.g. "Arthur Fils
* (tennis)" → "Arthur Fils") so the stored name and fuzzy matching use the plain
* name the raw externalId (with the disambiguator) is kept as the stable key.
*/
export function canonicalPlayerName(p: FetchedPlayer): string {
return (p.externalId ?? p.name).replace(/\s*\([^)]*\)\s*$/, "").trim();
}
/**
* Turn a fetched draw into bracket rows with participants/winner resolved to ids.
* `winnerId`/`loserId` are set only when both players resolved and the source
* reported a winner. `isScoring` comes from the bracket template's round config.
*/
export function buildResolvedMatches(
draw: FetchedDraw,
keyToParticipantId: Map<string, string>,
roundIsScoring: Map<string, boolean>,
): ResolvedDrawMatch[] {
const resolved: ResolvedDrawMatch[] = [];
for (const round of draw.rounds) {
for (const m of round.matches) {
const p1 = m.player1 ? keyToParticipantId.get(playerKey(m.player1)) ?? null : null;
const p2 = m.player2 ? keyToParticipantId.get(playerKey(m.player2)) ?? null : null;
let winnerId: string | null = null;
let loserId: string | null = null;
if (m.winner === 1) {
winnerId = p1;
loserId = p2;
} else if (m.winner === 2) {
winnerId = p2;
loserId = p1;
}
resolved.push({
externalMatchId: m.externalMatchId,
round: m.round,
matchNumber: m.matchNumber,
participant1Id: p1,
participant2Id: p2,
winnerId,
loserId,
isScoring: roundIsScoring.get(m.round) ?? false,
});
}
}
return resolved;
}

View file

@ -42,3 +42,103 @@ export interface MatchSyncResult {
unmatchedTeams: { externalId: string; name: string }[];
errors: { externalMatchId: string; error: string }[];
}
// ---------------------------------------------------------------------------
// Tennis Grand Slam draw sync
//
// Unlike the team-sport `MatchSyncAdapter` (result-only updates on a
// pre-existing bracket), a tennis major must be populated from an empty draw:
// 128 players, scaffolded across all 7 rounds, with winners auto-scored as the
// source reports them. `DrawSyncAdapter` returns the full draw; `syncTennisDraw`
// (index.ts) auto-creates/links participants, builds the bracket, propagates
// participants to linked sibling seasons, and runs the qualifying-points scorer.
// ---------------------------------------------------------------------------
/** A competitor in a fetched draw match, as seen by the external source. */
export interface FetchedPlayer {
/** Display name, e.g. "Jannik Sinner". */
name: string;
/**
* Stable source identifier, the primary key against
* `seasonParticipants.externalId`. For Wikipedia this is the article link
* target (stable across years). Null when the source gives only plain text
* (e.g. a qualifier with no article).
*/
externalId: string | null;
/** Tournament seed, if seeded. */
seed: number | null;
}
/** A single fetched draw match. */
export interface FetchedDrawMatch {
/** Stable, source-derived id, e.g. "wimbledon-2025-ms-Round-of-16-m03". */
externalMatchId: string;
/** Template round name, e.g. "Round of 128" … "Final". */
round: string;
/** 1-based position within the round (cosmetic ordering only). */
matchNumber: number;
player1: FetchedPlayer | null;
player2: FetchedPlayer | null;
/** Which slot won; null while undrawn or not yet completed. */
winner: 1 | 2 | null;
}
export interface FetchedRound {
/** Template round name. */
roundName: string;
matches: FetchedDrawMatch[];
}
/** A complete (or partially-completed) knockout draw. */
export interface FetchedDraw {
/** Bracket template this draw maps onto, e.g. "tennis_128". */
templateId: string;
/** Ordered earliest → latest round (Round of 128 → Final). */
rounds: FetchedRound[];
}
/** Adapter that fetches a knockout draw from an external source. */
export interface DrawSyncAdapter {
/** @param sourceKey provider locator (Wikipedia article title, etc.). */
fetchDraw(sourceKey: string): Promise<FetchedDraw>;
}
/** A drawn player that couldn't be confidently auto-resolved to a participant. */
export interface UnmatchedPlayer {
name: string;
externalId: string | null;
}
export interface DrawSyncResult {
/** Matches inserted or updated in the bracket. */
matchesWritten: number;
/** Of those, how many carried a completed result this sync. */
completed: number;
/** New participants auto-created (across all linked seasons). */
participantsCreated: number;
/** Ambiguous players queued for admin reconciliation rather than guessed. */
unmatched: UnmatchedPlayer[];
}
/** Read-only preview of what a draw sync would do, with no DB writes. */
export interface DrawSyncPreview {
/** Resolved Wikipedia article title. */
article: string;
totalPlayers: number;
/** Players that resolve to an existing participant. */
matched: number;
/** Players that would be auto-created. */
willCreate: UnmatchedPlayer[];
/** Of those, ones that resemble an existing participant (likely duplicates). */
possibleDuplicates: {
name: string;
externalId: string | null;
/** Existing participant this resembles. */
suggestion: string;
suggestionId: string;
}[];
/** Unfilled slots in the first round (e.g. a qualifier not yet drawn). */
tbdFirstRound: number;
totalMatches: number;
completedMatches: number;
}

View file

@ -0,0 +1,307 @@
/**
* Wikipedia tennis Grand Slam draw adapter.
*
* Source of truth: the singles-draw article (e.g.
* "2025 Wimbledon Championships Men's singles"), whose full bracket is encoded
* as MediaWiki bracket templates:
* - eight `{{16TeamBracket-Compact-Tennis5}}` sections (Round of 128 Round
* of 16), 16 players each = 128, and
* - one `{{8TeamBracket-Tennis5}}` finals bracket (Quarterfinals Final).
* (`-Tennis3` best-of-3 variants render women's draws identically for our needs.)
*
* The winner of each match is the slot whose `team` value is bolded (`'''…'''`)
* losers are never bolded which lets us derive winner/loser without parsing
* set scores. Player names come from `[[wikilinks]]`, whose target is a stable
* cross-year id used to match `seasonParticipants.externalId`.
*
* Parsing is split out (pure functions) so it can be unit-tested against a saved
* wikitext fixture without network access.
*/
import type {
DrawSyncAdapter,
FetchedDraw,
FetchedDrawMatch,
FetchedPlayer,
FetchedRound,
} from "./types";
const WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php";
const CONTACT = process.env.WIKIPEDIA_CONTACT_EMAIL ?? "admin@brackt.com";
const USER_AGENT = `Brackt.com tennis draw sync - ${CONTACT}`;
/** Round names for a 16-player draw section, earliest → latest. */
const SECTION_ROUNDS = ["Round of 128", "Round of 64", "Round of 32", "Round of 16"] as const;
/** Round names for the 8-player finals bracket, earliest → latest. */
const FINALS_ROUNDS = ["Quarterfinals", "Semifinals", "Final"] as const;
/**
* Accept either a plain article title or a pasted Wikipedia URL and return the
* article title. URLs like
* `https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_%E2%80%93_Men%27s_singles`
* become "2025 Wimbledon Championships Men's singles".
*/
export function articleTitleFromInput(input: string): string {
const trimmed = input.trim();
const match = trimmed.match(/\/wiki\/([^?#]+)/);
if (!match) return trimmed;
let title = match[1];
try {
title = decodeURIComponent(title);
} catch {
// leave as-is if it isn't valid percent-encoding
}
return title.replace(/_/g, " ").trim();
}
/** Turn an article title into a stable, id-safe slug. */
export function slugifyArticle(title: string): string {
return title
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "") // strip combining diacritics
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Scan wikitext for `{{<TemplateName>...}}` invocations, returning each body
* (text between the outer braces) with balanced-brace handling so nested
* templates like `{{flagicon|ITA}}` don't terminate the capture early.
*/
export function extractTemplates(wikitext: string, namePrefixes: string[]): { name: string; body: string }[] {
const results: { name: string; body: string }[] = [];
for (let i = 0; i < wikitext.length - 1; i++) {
if (wikitext[i] !== "{" || wikitext[i + 1] !== "{") continue;
const afterBraces = wikitext.slice(i + 2);
const match = afterBraces.match(/^\s*([A-Za-z0-9 _-]+)/);
const name = match?.[1]?.trim() ?? "";
if (!namePrefixes.some((p) => name.startsWith(p))) continue;
// Walk forward tracking {{ }} depth to find the matching close.
let depth = 0;
let j = i;
for (; j < wikitext.length - 1; j++) {
if (wikitext[j] === "{" && wikitext[j + 1] === "{") {
depth++;
j++;
} else if (wikitext[j] === "}" && wikitext[j + 1] === "}") {
depth--;
j++;
if (depth === 0) break;
}
}
const body = wikitext.slice(i + 2, j - 1);
results.push({ name, body });
i = j; // continue past this template
}
return results;
}
/**
* Split a template body into `key=value` params on top-level `|` only
* pipes inside `{{…}}` or `[[…]]` are part of a value (flag templates, piped
* wikilinks) and must not split.
*/
export function parseTemplateParams(body: string): Map<string, string> {
const params = new Map<string, string>();
let braceDepth = 0;
let linkDepth = 0;
let current = "";
const parts: string[] = [];
for (let i = 0; i < body.length; i++) {
const two = body.slice(i, i + 2);
if (two === "{{") { braceDepth++; current += two; i++; continue; }
if (two === "}}") { braceDepth--; current += two; i++; continue; }
if (two === "[[") { linkDepth++; current += two; i++; continue; }
if (two === "]]") { linkDepth--; current += two; i++; continue; }
if (body[i] === "|" && braceDepth === 0 && linkDepth === 0) {
parts.push(current);
current = "";
continue;
}
current += body[i];
}
parts.push(current);
for (const part of parts) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key) params.set(key, value);
}
return params;
}
/**
* Parse a bracket `team` value into a player + whether it won.
* Returns null for an empty/Bye slot.
*/
export function parsePlayerCell(value: string): { player: FetchedPlayer; isWinner: boolean } | null {
if (!value) return null;
// Winner detection: only the winning slot's name is bolded.
const isWinner = value.includes("'''");
let text = value.replace(/'''/g, "");
// Extract the wikilink FIRST, before stripping templates. A team cell may wrap
// the flag + link in a display template, e.g.
// {{nowrap|{{flagicon|ESP}} [[Alejandro Davidovich Fokina|A Davidovich Fokina]]}}
// Blanket template-stripping would, after removing the inner {{flagicon}}, treat
// the outer {{nowrap| … [[link]] … }} as a single template and delete the link
// with it — yielding an empty cell (TBD). Pulling the link out first avoids that.
const linkMatch = text.match(/\[\[([^\]]+)\]\]/);
let name: string;
let externalId: string | null;
if (linkMatch) {
const [target, display] = linkMatch[1].split("|");
externalId = target.trim();
name = (display ?? target).trim();
} else {
// No link: unwrap display templates ({{nowrap|X}}) to their content, drop flag
// templates, and keep whatever plain text remains (e.g. an unlinked qualifier).
let prev: string;
do {
prev = text;
text = text.replace(/\{\{\s*(?:nowrap|nobr)\s*\|([^{}]*)\}\}/gi, "$1");
} while (text !== prev);
do {
prev = text;
text = text.replace(/\{\{[^{}]*\}\}/g, " ");
} while (text !== prev);
name = text.replace(/\[\[|\]\]/g, "").trim();
externalId = null;
}
name = name.replace(/\s+/g, " ").trim();
// Treat empty slots and not-yet-determined placeholders (an unfilled qualifier,
// a bye, a TBD feeder) as no player → the bracket shows TBD rather than
// inventing a shared "Qualifier" participant across many slots.
if (!name || /^(bye|tbd|qualifier|to be determined)$/i.test(name)) return null;
return { player: { name, externalId, seed: null }, isWinner };
}
/**
* Build matches for one round of a bracket. Within a round, consecutive slots
* pair up: (team1,team2)=match1, (team3,team4)=match2,
*/
function parseRound(
params: Map<string, string>,
rdIndex: number,
roundName: string,
idPrefix: string,
matchNumberOffset: number,
): FetchedDrawMatch[] {
// Collect team slot tokens present for this round, preserving the raw token
// (slots are zero-padded in the compact section brackets, e.g. "team01", but
// unpadded in the finals bracket, e.g. "team1"). Sort by numeric value.
const slots: string[] = [];
for (const key of params.keys()) {
const m = key.match(new RegExp(`^RD${rdIndex}-team(\\d+)$`));
if (m) slots.push(m[1]);
}
slots.sort((a, b) => Number(a) - Number(b));
const matches: FetchedDrawMatch[] = [];
for (let p = 0; p < slots.length; p += 2) {
const slot1 = slots[p];
const slot2 = slots[p + 1];
const cell1 = parsePlayerCell(params.get(`RD${rdIndex}-team${slot1}`) ?? "");
const cell2 = slot2 !== undefined ? parsePlayerCell(params.get(`RD${rdIndex}-team${slot2}`) ?? "") : null;
const seed1 = params.get(`RD${rdIndex}-seed${slot1}`);
const seed2 = slot2 !== undefined ? params.get(`RD${rdIndex}-seed${slot2}`) : undefined;
const player1 = cell1 ? { ...cell1.player, seed: parseSeed(seed1) } : null;
const player2 = cell2 ? { ...cell2.player, seed: parseSeed(seed2) } : null;
let winner: 1 | 2 | null = null;
if (cell1?.isWinner) winner = 1;
else if (cell2?.isWinner) winner = 2;
const matchNumber = matchNumberOffset + matches.length + 1;
matches.push({
externalMatchId: `${idPrefix}:${roundName.replace(/\s+/g, "-")}:m${matchNumber}`,
round: roundName,
matchNumber,
player1,
player2,
winner,
});
}
return matches;
}
function parseSeed(raw: string | undefined): number | null {
if (!raw) return null;
const n = parseInt(raw.replace(/[^0-9]/g, ""), 10);
return Number.isFinite(n) ? n : null;
}
/**
* Parse a full Grand Slam singles draw article into a `FetchedDraw`.
* @param wikitext raw article wikitext
* @param articleTitle used to build stable match ids
*/
export function parseTennisDrawWikitext(wikitext: string, articleTitle: string): FetchedDraw {
const slug = slugifyArticle(articleTitle);
const sectionBrackets = extractTemplates(wikitext, ["16TeamBracket"]);
const finalsBrackets = extractTemplates(wikitext, ["8TeamBracket"]);
if (finalsBrackets.length !== 1 || sectionBrackets.length !== 8) {
throw new Error(
`Unexpected tennis draw layout in "${articleTitle}": found ${sectionBrackets.length} ` +
`16-team section bracket(s) and ${finalsBrackets.length} 8-team finals bracket(s); ` +
"expected 8 sections + 1 finals. Template variant may be unsupported.",
);
}
// Accumulate matches per round across all sections (section order is cosmetic).
const roundMatches = new Map<string, FetchedDrawMatch[]>();
const pushMatches = (round: string, ms: FetchedDrawMatch[]) => {
const existing = roundMatches.get(round) ?? [];
roundMatches.set(round, existing.concat(ms));
};
sectionBrackets.forEach((bracket, sectionIdx) => {
const params = parseTemplateParams(bracket.body);
SECTION_ROUNDS.forEach((roundName, rdIdx) => {
const offset = roundMatches.get(roundName)?.length ?? 0;
pushMatches(roundName, parseRound(params, rdIdx + 1, roundName, `${slug}:s${sectionIdx}`, offset));
});
});
const finalsParams = parseTemplateParams(finalsBrackets[0].body);
FINALS_ROUNDS.forEach((roundName, rdIdx) => {
pushMatches(roundName, parseRound(finalsParams, rdIdx + 1, roundName, `${slug}:f`, 0));
});
const orderedRoundNames = [...SECTION_ROUNDS, ...FINALS_ROUNDS];
const rounds: FetchedRound[] = orderedRoundNames.map((roundName) => ({
roundName,
matches: roundMatches.get(roundName) ?? [],
}));
return { templateId: "tennis_128", rounds };
}
export class WikipediaTennisAdapter implements DrawSyncAdapter {
async fetchDraw(sourceKey: string): Promise<FetchedDraw> {
const title = articleTitleFromInput(sourceKey);
const url = `${WIKIPEDIA_API}?action=parse&prop=wikitext&format=json&formatversion=2&redirects=1&page=${encodeURIComponent(title)}`;
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
if (!response.ok) {
throw new Error(`Wikipedia API returned ${response.status}: ${response.statusText}`);
}
const json = (await response.json()) as {
error?: { info?: string };
parse?: { wikitext?: string };
};
if (json.error) {
throw new Error(`Wikipedia API error for "${title}": ${json.error.info ?? "unknown error"}`);
}
const wikitext = json.parse?.wikitext;
if (!wikitext) {
throw new Error(`No wikitext returned for "${title}"`);
}
return parseTennisDrawWikitext(wikitext, title);
}
}

View file

@ -641,6 +641,10 @@ export const scoringEvents = pgTable("scoring_events", {
scoringStartsAtRound: varchar("scoring_starts_at_round", { length: 50 }), // "Elite Eight", "Quarterfinals", etc.
// Per-event region config for NCAA-style brackets (overrides template defaults)
bracketRegionConfig: jsonb("bracket_region_config"), // BracketRegion[] | null
// External data-source identifier for auto-populating this event from a feed.
// For Wikipedia tennis draws this is the article title, e.g.
// "2025 Wimbledon Championships Men's singles". Null = no external source.
externalSourceKey: varchar("external_source_key", { length: 255 }),
isComplete: boolean("is_complete").notNull().default(false),
completedAt: timestamp("completed_at"),
// For tournament-linked majors shared across windows: marks the single window

View file

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

File diff suppressed because it is too large Load diff

View file

@ -862,6 +862,13 @@
"when": 1782103950846,
"tag": "0122_soft_longshot",
"breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1782250520349,
"tag": "0123_previous_expediter",
"breakpoints": true
}
]
}