Merge pull request 'Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia' (#112) from feat/tennis-draw-sync into main
Reviewed-on: #112
This commit is contained in:
commit
dfbb25771d
20 changed files with 10616 additions and 15 deletions
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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) ────────────────────────────────────────────
|
// ── QP values (the worked example) ────────────────────────────────────────────
|
||||||
|
|
||||||
describe("guaranteed-minimum QP per outcome (default config)", () => {
|
describe("guaranteed-minimum QP per outcome (default config)", () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 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) {
|
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 });
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'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.
|
{/* 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. */}
|
||||||
|
|
|
||||||
|
|
@ -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
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 { 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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
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 }[];
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
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.
|
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
|
||||||
|
|
|
||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue