brackt/app/services/match-sync/tennis-draw-mapping.ts
Chris Parsons 81d063faa1
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m13s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m23s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Auto-populate & auto-score tennis Grand Slam brackets from Wikipedia
Add a per-event "Sync Draw" that pulls a tennis major's full 128-player
draw from its Wikipedia article, auto-creates/links participants (and
propagates them to every linked sibling season), builds the bracket, and
runs the qualifying-points scorer. Re-running advances the bracket and
scoring as matches complete.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:48:00 -07:00

63 lines
2.4 KiB
TypeScript

/**
* 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;
}