Merge pull request 'Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia' (#112) from feat/tennis-draw-sync into main
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m26s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m22s
🚀 Deploy / 🐳 Build (push) Successful in 1m13s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s

Reviewed-on: #112
This commit is contained in:
chrisp 2026-06-27 04:55:29 +00:00
commit dfbb25771d
20 changed files with 10616 additions and 15 deletions

View file

@ -14,6 +14,11 @@ describe("normalizeTeamName", () => {
const name = "oklahoma city thunder"; const name = "oklahoma city thunder";
expect(normalizeTeamName(name)).toBe(name); 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", () => { describe("findMatchingTeamName", () => {
@ -38,6 +43,12 @@ describe("findMatchingTeamName", () => {
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder"); 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", () => { it("returns null for unmatched name", () => {
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull(); expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
}); });

View file

@ -1,9 +1,15 @@
/** /**
* Normalize a team name for fuzzy matching across data sources. * Normalize a team/participant name for fuzzy matching across data sources.
* Lowercases, trims, and collapses whitespace. * Folds accents (so "Stéfanos Tsitsipás" matches a plain "Stefanos Tsitsipas"),
* lowercases, trims, and collapses whitespace.
*/ */
export function normalizeTeamName(name: string): string { 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) ──────────────────────────────────────────── // ── QP values (the worked example) ────────────────────────────────────────────
describe("guaranteed-minimum QP per outcome (default config)", () => { describe("guaranteed-minimum QP per outcome (default config)", () => {

View file

@ -142,6 +142,96 @@ export async function setMatchWinner(
return match; 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 * Generate a standard single elimination bracket structure
*/ */

View file

@ -727,15 +727,38 @@ export function deriveBracketQualifyingStates(
} }
if (!highest) { if (!highest) {
// In the bracket but no match resolved yet → entry floor. // In the bracket but no match resolved yet → sitting in the first round.
const cfg = firstScoringRound ? getConfig(firstScoringRound.name) : null; // 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; if (!cfg) continue;
result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount }); result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount });
continue; continue;
} }
const cfg = getConfig(highest); 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) { if (cfg.winnerFloor === null) {
// Won the finalization round → champion (or 3rd-place-game winner). // Won the finalization round → champion (or 3rd-place-game winner).
result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 }); result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 });

View file

@ -42,6 +42,8 @@ export interface UpdateScoringEventData {
scoringStartsAtRound?: string; scoringStartsAtRound?: string;
/** Per-event region config for NCAA-style brackets (overrides template defaults) */ /** Per-event region config for NCAA-style brackets (overrides template defaults) */
bracketRegionConfig?: BracketRegion[] | null; 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.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound; if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig; if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
if (data.externalSourceKey !== undefined) updateData.externalSourceKey = data.externalSourceKey;
const [updated] = await db const [updated] = await db
.update(schema.scoringEvents) .update(schema.scoringEvents)

View file

@ -1,7 +1,11 @@
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { findSportsSeasonById } from "~/models/sports-season"; 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 { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
import { import {
findPlayoffMatchesByEventId, findPlayoffMatchesByEventId,
@ -62,6 +66,8 @@ import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server"; import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results"; 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) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); 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") { if (intent === "generate-bracket") {
const templateId = formData.get("templateId"); 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 { Fragment, useState, useEffect, useMemo, useRef } from "react";
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils"; import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
@ -93,6 +93,62 @@ function GroupMatchScheduleForm({
export { loader, action }; 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({ export default function EventBracket({
loaderData, loaderData,
actionData, actionData,
@ -375,6 +431,164 @@ export default function EventBracket({
</div> </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. {/* Reprocess Bracket - Full rebuild of participant results.
Hidden once every match is complete at that point all placements are final Hidden once every match is complete at that point all placements are final
and "finalize bracket" should be used instead. */} 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 { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { requireCronSecret } from "~/lib/cron-auth"; 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 }) { export async function action({ request }: { request: Request }) {
requireCronSecret(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 { database } from "~/database/context";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as schema from "~/database/schema"; 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 { upsertSeasonMatchBulk, upsertMatchSubGame } from "~/models/season-match";
import { setMatchWinner, updatePlayoffMatch, advanceWinnerTemplate, findPlayoffMatchesByEventId, doesLoserAdvance } from "~/models/playoff-match"; import {
import { processMatchResult, autoCompleteRoundIfDone } from "~/models/scoring-calculator"; setMatchWinner,
import { findMatchingTeamName } from "~/lib/normalize-team-name"; 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 { getBracketTemplate } from "~/lib/bracket-templates";
import { PandaScoreMatchSyncAdapter } from "./pandascore"; import { PandaScoreMatchSyncAdapter } from "./pandascore";
import { EspnScheduleAdapter } from "./espn-schedule"; 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"; import { logger } from "~/lib/logger";
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter { function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
@ -269,3 +300,336 @@ export async function syncMatches(sportsSeasonId: string): Promise<MatchSyncResu
return { swissCreated, swissUpdated, playoffUpdated, unmatchedTeams, errors }; 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 }[]; unmatchedTeams: { externalId: string; name: string }[];
errors: { externalMatchId: string; error: 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. scoringStartsAtRound: varchar("scoring_starts_at_round", { length: 50 }), // "Elite Eight", "Quarterfinals", etc.
// Per-event region config for NCAA-style brackets (overrides template defaults) // Per-event region config for NCAA-style brackets (overrides template defaults)
bracketRegionConfig: jsonb("bracket_region_config"), // BracketRegion[] | null 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), isComplete: boolean("is_complete").notNull().default(false),
completedAt: timestamp("completed_at"), completedAt: timestamp("completed_at"),
// For tournament-linked majors shared across windows: marks the single window // 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, "when": 1782103950846,
"tag": "0122_soft_longshot", "tag": "0122_soft_longshot",
"breakpoints": true "breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1782250520349,
"tag": "0123_previous_expediter",
"breakpoints": true
} }
] ]
} }