Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia #112
20 changed files with 10616 additions and 15 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 T9–16", () => {
|
||||
// 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 T9–16 and floors the winner at T5–8", () => {
|
||||
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)", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 T9–16). 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 });
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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'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's the same player (links
|
||||
the existing participant so it matches);{" "}
|
||||
<span className="font-medium">Create as new</span> if they'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. */}
|
||||
|
|
|
|||
|
|
@ -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
147
app/services/match-sync/__tests__/tennis-draw-mapping.test.ts
Normal file
147
app/services/match-sync/__tests__/tennis-draw-mapping.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
176
app/services/match-sync/__tests__/wikipedia-tennis.test.ts
Normal file
176
app/services/match-sync/__tests__/wikipedia-tennis.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
63
app/services/match-sync/tennis-draw-mapping.ts
Normal file
63
app/services/match-sync/tennis-draw-mapping.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
307
app/services/match-sync/wikipedia-tennis.ts
Normal file
307
app/services/match-sync/wikipedia-tennis.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
1
drizzle/0123_previous_expediter.sql
Normal file
1
drizzle/0123_previous_expediter.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "scoring_events" ADD COLUMN "external_source_key" varchar(255);
|
||||
6747
drizzle/meta/0123_snapshot.json
Normal file
6747
drizzle/meta/0123_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -862,6 +862,13 @@
|
|||
"when": 1782103950846,
|
||||
"tag": "0122_soft_longshot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 123,
|
||||
"version": "7",
|
||||
"when": 1782250520349,
|
||||
"tag": "0123_previous_expediter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue