2026-06-12 22:35:35 +00:00
import { database } from "~/database/context" ;
import { eq } from "drizzle-orm" ;
import * as schema from "~/database/schema" ;
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 00:32:35 -07:00
import {
findParticipantsBySportsSeasonId ,
updateParticipant ,
createParticipant ,
createManyParticipants ,
} from "~/models/season-participant" ;
2026-06-12 22:35:35 +00:00
import { upsertSeasonMatchBulk , upsertMatchSubGame } from "~/models/season-match" ;
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 00:32:35 -07:00
import {
setMatchWinner ,
updatePlayoffMatch ,
advanceWinnerTemplate ,
findPlayoffMatchesByEventId ,
doesLoserAdvance ,
populateBracketFromDraw ,
} from "~/models/playoff-match" ;
import {
processMatchResult ,
autoCompleteRoundIfDone ,
processQualifyingBracketEvent ,
recalculateAffectedLeagues ,
} from "~/models/scoring-calculator" ;
import { fanOutMajorIfPrimary } from "~/services/sync-tournament-results" ;
2026-07-01 22:14:51 +00:00
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server" ;
import {
recalculateParticipantQP ,
diffChangedQualifyingPoints ,
} from "~/models/qualifying-points" ;
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 00:32:35 -07:00
import { getScoringEventById , updateScoringEvent , isReadOnlySibling } from "~/models/scoring-event" ;
import { findMatchingTeamName , normalizeTeamName } from "~/lib/normalize-team-name" ;
import { buildUnmatchedTeamResolutionView } from "~/lib/unmatched-team-reconciliation" ;
2026-06-12 22:35:35 +00:00
import { getBracketTemplate } from "~/lib/bracket-templates" ;
import { PandaScoreMatchSyncAdapter } from "./pandascore" ;
import { EspnScheduleAdapter } from "./espn-schedule" ;
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 00:32:35 -07:00
import { WikipediaTennisAdapter } from "./wikipedia-tennis" ;
import { playerKey , canonicalPlayerName , buildResolvedMatches } from "./tennis-draw-mapping" ;
import type {
MatchSyncAdapter ,
MatchSyncResult ,
DrawSyncAdapter ,
DrawSyncResult ,
DrawSyncPreview ,
FetchedDraw ,
FetchedPlayer ,
} from "./types" ;
2026-06-12 22:35:35 +00:00
import { logger } from "~/lib/logger" ;
function getMatchSyncAdapter ( simulatorType : string ) : MatchSyncAdapter {
switch ( simulatorType ) {
case "cs2_major_qualifying_points" :
return new PandaScoreMatchSyncAdapter ( ) ;
case "mlb_bracket" :
return new EspnScheduleAdapter ( "baseball/mlb" , 3 ) ;
case "nba_bracket" :
return new EspnScheduleAdapter ( "basketball/nba" , 3 ) ;
case "mls_bracket" :
return new EspnScheduleAdapter ( "soccer/mls" , 3 ) ;
case "wnba_bracket" :
return new EspnScheduleAdapter ( "basketball/wnba" , 3 ) ;
case "nhl_bracket" :
return new EspnScheduleAdapter ( "hockey/nhl" , 3 ) ;
default :
throw new Error (
` No match sync adapter available for simulator type " ${ simulatorType } ". ` +
"CS2, MLB, NBA, MLS, WNBA, and NHL are currently supported."
) ;
}
}
export async function syncMatches ( sportsSeasonId : string ) : Promise < MatchSyncResult > {
const db = database ( ) ;
const sportsSeason = await db . query . sportsSeasons . findFirst ( {
where : eq ( schema . sportsSeasons . id , sportsSeasonId ) ,
with : { sport : true } ,
} ) ;
if ( ! sportsSeason ) throw new Error ( ` Sports season ${ sportsSeasonId } not found ` ) ;
const externalSeasonId = sportsSeason . externalSeasonId ;
if ( ! externalSeasonId ) {
throw new Error ( ` Sports season " ${ sportsSeason . name } " has no externalSeasonId configured ` ) ;
}
const simulatorType = sportsSeason . sport ? . simulatorType ;
if ( ! simulatorType ) {
throw new Error ( ` Sport " ${ sportsSeason . sport ? . name ? ? "(none)" } " has no simulator type configured ` ) ;
}
const adapter = getMatchSyncAdapter ( simulatorType ) ;
const fetchedMatches = await adapter . fetchMatches ( externalSeasonId ) ;
// Load participants, build lookup maps
const participants = await findParticipantsBySportsSeasonId ( sportsSeasonId ) ;
const participantNames = participants . map ( ( p ) = > p . name ) ;
const participantByExternalId = new Map (
participants . filter ( ( p ) = > p . externalId ) . map ( ( p ) = > [ p . externalId as string , p ] )
) ;
const participantByName = new Map ( participants . map ( ( p ) = > [ p . name , p ] ) ) ;
async function resolveParticipant ( externalId : string , name : string ) {
let participant = participantByExternalId . get ( externalId ) ? ? null ;
if ( ! participant ) {
const matchedName = findMatchingTeamName ( name , participantNames ) ;
if ( matchedName ) {
participant = participantByName . get ( matchedName ) ? ? null ;
if ( participant && ! participant . externalId ) {
await updateParticipant ( participant . id , { externalId } ) ;
participantByExternalId . set ( externalId , participant ) ;
}
}
}
return participant ;
}
// Find the scoring event for this sports season (needed for CS2 stage lookup + playoff sync)
const scoringEvents = await db . query . scoringEvents . findMany ( {
where : eq ( schema . scoringEvents . sportsSeasonId , sportsSeasonId ) ,
} ) ;
// Split matches: Swiss (matchStage set) vs playoff bracket (matchStage null)
const swissMatches = fetchedMatches . filter ( ( m ) = > m . matchStage !== null && m . matchStage !== undefined ) ;
const bracketMatches = fetchedMatches . filter ( ( m ) = > m . matchStage === null || m . matchStage === undefined ) ;
const unmatchedTeams : MatchSyncResult [ "unmatchedTeams" ] = [ ] ;
const errors : MatchSyncResult [ "errors" ] = [ ] ;
let swissCreated = 0 ;
let swissUpdated = 0 ;
let playoffUpdated = 0 ;
// --- Swiss matches → season_matches ---
if ( swissMatches . length > 0 ) {
// For CS2, use the scoring event that has cs2_major_stage_results
const cs2Event = scoringEvents . find ( ( e ) = > e . isQualifyingEvent ) ? ? scoringEvents [ 0 ] ? ? null ;
const toUpsert = [ ] ;
for ( const m of swissMatches ) {
const p1 = await resolveParticipant ( m . team1ExternalId , m . team1Name ) ;
const p2 = await resolveParticipant ( m . team2ExternalId , m . team2Name ) ;
if ( ! p1 ) unmatchedTeams . push ( { externalId : m.team1ExternalId , name : m.team1Name } ) ;
if ( ! p2 ) unmatchedTeams . push ( { externalId : m.team2ExternalId , name : m.team2Name } ) ;
const winnerParticipant = m . winnerExternalId
? ( await resolveParticipant ( m . winnerExternalId , "" ) )
: null ;
toUpsert . push ( {
sportsSeasonId ,
scoringEventId : cs2Event?.id ? ? null ,
participant1Id : p1?.id ? ? null ,
participant2Id : p2?.id ? ? null ,
winnerId : winnerParticipant?.id ? ? null ,
participant1Score : m.team1Score ,
participant2Score : m.team2Score ,
matchStage : m.matchStage ? ? null ,
matchRound : m.matchRound ? ? null ,
matchday : m.matchday ? ? null ,
isSeries : m.isSeries ? ? false ,
status : m.status ,
scheduledAt : m.scheduledAt ,
startedAt : m.startedAt ,
completedAt : m.completedAt ,
externalMatchId : m.externalMatchId ,
} ) ;
}
const upserted = await upsertSeasonMatchBulk ( toUpsert ) ;
// Track created vs updated (rough heuristic: if completedAt is within last 2 min, it's new)
swissCreated = upserted . filter ( ( r ) = > {
const diff = r . createdAt ? Date . now ( ) - r . createdAt . getTime ( ) : Infinity ;
return diff < 120 _000 ;
} ) . length ;
swissUpdated = upserted . length - swissCreated ;
// Upsert sub-games (maps) for matches with subGames
for ( const m of swissMatches ) {
if ( ! m . subGames ? . length ) continue ;
const upsertedMatch = upserted . find ( ( u ) = > u . externalMatchId === m . externalMatchId ) ;
if ( ! upsertedMatch ) continue ;
for ( const g of m . subGames ) {
const winnerParticipant = g . winnerExternalId
? ( await resolveParticipant ( g . winnerExternalId , "" ) )
: null ;
try {
await upsertMatchSubGame ( {
seasonMatchId : upsertedMatch.id ,
gameNumber : g.gameNumber ,
gameLabel : g.gameLabel ? ? null ,
participant1Score : g.team1Score ,
participant2Score : g.team2Score ,
winnerId : winnerParticipant?.id ? ? null ,
status : g.status ,
externalGameId : g.externalGameId ? ? null ,
} ) ;
} catch ( err ) {
logger . error ( ` [match-sync] Error upserting sub-game ${ g . gameNumber } for match ${ m . externalMatchId } : ` , err ) ;
}
}
}
}
// --- Bracket matches → playoff_matches ---
if ( bracketMatches . length > 0 && scoringEvents . length === 0 ) {
logger . warn ( ` [match-sync] ${ bracketMatches . length } bracket match(es) found but no scoring events exist for season ${ sportsSeasonId } — bracket sync skipped ` ) ;
}
if ( bracketMatches . length > 0 ) {
for ( const event of scoringEvents ) {
const existingPlayoffMatches = await findPlayoffMatchesByEventId ( event . id ) ;
if ( existingPlayoffMatches . length === 0 ) continue ;
const bracketTemplate = event . bracketTemplateId
? getBracketTemplate ( event . bracketTemplateId )
: null ;
for ( const m of bracketMatches ) {
if ( m . status !== "complete" ) continue ;
const p1 = await resolveParticipant ( m . team1ExternalId , m . team1Name ) ;
const p2 = await resolveParticipant ( m . team2ExternalId , m . team2Name ) ;
if ( ! p1 || ! p2 ) continue ;
const winnerParticipant = m . winnerExternalId
? await resolveParticipant ( m . winnerExternalId , "" )
: null ;
if ( ! winnerParticipant ) continue ;
const loserId = winnerParticipant . id === p1 . id ? p2.id : p1.id ;
// Find the matching playoff_matches row: prefer external_match_id match, fall back to participant ID match
let playoffMatch = existingPlayoffMatches . find (
( pm ) = > pm . externalMatchId === m . externalMatchId
) ;
if ( ! playoffMatch ) {
playoffMatch = existingPlayoffMatches . find (
( pm ) = >
! pm . isComplete &&
( ( pm . participant1Id === p1 . id && pm . participant2Id === p2 . id ) ||
( pm . participant1Id === p2 . id && pm . participant2Id === p1 . id ) )
) ;
}
if ( ! playoffMatch ) continue ;
if ( playoffMatch . isComplete && playoffMatch . winnerId ) continue ; // idempotent
try {
await setMatchWinner (
playoffMatch . id ,
winnerParticipant . id ,
loserId ,
winnerParticipant . id === p1 . id ? ( m . team1Score ? ? undefined ) : ( m . team2Score ? ? undefined ) ,
winnerParticipant . id === p1 . id ? ( m . team2Score ? ? undefined ) : ( m . team1Score ? ? undefined )
) ;
// Set external_match_id for future upserts
if ( ! playoffMatch . externalMatchId ) {
await updatePlayoffMatch ( playoffMatch . id , { externalMatchId : m.externalMatchId } ) ;
}
if ( bracketTemplate ) {
try {
await advanceWinnerTemplate ( playoffMatch . id , winnerParticipant . id , bracketTemplate ) ;
} catch ( err ) {
if ( ! ( err instanceof Error && err . message . includes ( "already filled" ) ) ) throw err ;
}
}
const roundIsScoring = bracketTemplate ? . rounds . find (
( r ) = > r . name === playoffMatch . round
) ? . isScoring ;
await processMatchResult ( {
round : playoffMatch.round ,
winnerId : winnerParticipant.id ,
loserId ,
isScoring : roundIsScoring !== undefined ? roundIsScoring : ( playoffMatch . isScoring ? ? true ) ,
sportsSeasonId ,
bracketTemplateId : event.bracketTemplateId ? ? null ,
eventId : event.id ,
eventName : event.name ? ? undefined ,
matchId : playoffMatch.id ,
loserAdvances : event.bracketTemplateId
? doesLoserAdvance ( playoffMatch . round , playoffMatch . matchNumber , event . bracketTemplateId )
: false ,
} ) ;
await autoCompleteRoundIfDone ( event . id , playoffMatch . round , sportsSeasonId , db ) ;
playoffUpdated ++ ;
} catch ( err ) {
logger . error ( ` [match-sync] Error processing playoff match ${ m . externalMatchId } : ` , err ) ;
errors . push ( {
externalMatchId : m.externalMatchId ,
error : err instanceof Error ? err.message : String ( err ) ,
} ) ;
}
}
}
}
return { swissCreated , swissUpdated , playoffUpdated , unmatchedTeams , errors } ;
}
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 00:32:35 -07:00
// ===========================================================================
// 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 ,
} ) ;
}
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
const { written , completed , newlyDecidedLoserIds } = await populateBracketFromDraw (
eventId ,
resolvedMatches ,
) ;
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 00:32:35 -07:00
// ---- Score (qualifying points) + fan out to siblings ----------------------
2026-07-01 22:14:51 +00:00
// Re-derive QP from the bracket and learn which participants' QP changed so the
// Discord notification below only announces new/changed results on a re-sync.
const changedParticipantIds = await rescoreTennisBracketAndDetectChanges (
eventId ,
sportsSeasonId ,
db ,
) ;
await recalculateAffectedLeagues ( sportsSeasonId , db , {
eventId ,
eventName : event.name ? ? undefined ,
} ) ;
await fanOutMajorIfPrimary (
{ id : event.id , isPrimary : event.isPrimary , tournamentId : event.tournamentId } ,
{ markComplete : false } ,
) ;
// Announce QP changes for the primary window. Sibling windows are announced by
// the fan-out (via processQualifyingEvent); the primary is scored directly by
// processQualifyingBracketEvent, which does NOT notify — so do it here. Run
// outside the rescore transaction so the webhook HTTP call neither holds the
// transaction open nor rolls back the score if Discord fails.
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
//
// Also announce players knocked out this sync in a non-scoring round (rounds
// 1– 3 of a Grand Slam), who earn no QP and so never appear via changedParticipantIds.
// Fire even when no QP changed, so an early-round elimination is still surfaced.
const newlyEliminatedIds = new Set ( newlyDecidedLoserIds ) ;
if ( changedParticipantIds . size > 0 || newlyEliminatedIds . size > 0 ) {
2026-07-01 22:14:51 +00:00
try {
Announce drafted tennis players eliminated in non-scoring rounds
A player drafted in a tennis major (e.g. Jakob Mensik, out in the Round of
64) got no Discord announcement when the bracket was scored by sync. The
first three Grand Slam rounds are non-scoring, so an early-round loser earns
0 QP, gets no event_results row, and is dropped from the
"Qualifying Points Update" notification — the only announcement the tennis
sync emits mid-tournament.
Detect players knocked out on each sync and surface them:
- populateBracketFromDraw now returns newlyDecidedLoserIds: losers of
matches that transition to complete on this run. Idempotent across
re-syncs since playoff_matches persist, so a knockout is announced once.
- syncTennisDraw threads that set into notifyQualifyingPointsUpdate and
fires the notification even when no QP changed.
- notifyQualifyingPointsUpdate builds an eliminated list scoped to players
drafted in the league, deduped against QP earners (so a Round-of-16 loser
who scores isn't listed twice), tagging the drafting manager.
- sendQualifyingPointsUpdateNotification renders a "Knocked Out" section and
pings those managers; the QP Standings block is skipped when a sync only
reports knockouts.
Tests cover the new detection, dedup, manager tagging, knockout-only
notifications, and rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkPWxSCunhPFknNUTXm4aZ
2026-07-03 14:42:06 +00:00
await notifyQualifyingPointsUpdate (
sportsSeasonId ,
eventId ,
db ,
changedParticipantIds ,
newlyEliminatedIds ,
) ;
2026-07-01 22:14:51 +00:00
} catch ( error ) {
logger . error ( ` [syncTennisDraw] QP Discord notification failed for event ${ eventId } : ` , error ) ;
}
}
return { matchesWritten : written , completed , participantsCreated , unmatched } ;
}
/ * *
* Re - derive a tennis major ' s qualifying points from its bracket and report which
* season participants ' awarded QP changed since the previous scoring .
*
* The synced draw is authoritative for tennis , so this clears ALL event_results
* for the event ( including any manually - entered rows , e . g . a notParticipating
* withdrawal ) and rebuilds them from the bracket . Change detection therefore
* snapshots each participant ' s awarded QP * before * the delete and diffs it against
* the freshly written rows : a value that differs , or a participant scored for the
* first time ( null → value ) , counts as changed . Wrapped in a transaction so a
* mid - rescore failure can ' t leave the event with results deleted - but - not - rebuilt .
*
* writeEventResultsQP ( inside processQualifyingBracketEvent ) upserts but never
* deletes , so QP totals for participants who no longer earn points ( e . g . an
* early - round loser dropped from the rewritten results — only Round of 16 + losers
* score ) are recalculated by hand .
*
* Returns the set of season_participant ids whose QP changed , used to scope the
* QP Discord notification so a re - sync that changes nothing announces nothing .
* /
export async function rescoreTennisBracketAndDetectChanges (
eventId : string ,
sportsSeasonId : string ,
db : ReturnType < typeof database > ,
) : Promise < Set < string > > {
let changed = new Set < string > ( ) ;
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 00:32:35 -07:00
await db . transaction ( async ( tx ) = > {
const beforeRows = await tx
2026-07-01 22:14:51 +00:00
. select ( {
id : schema.eventResults.seasonParticipantId ,
qp : schema.eventResults.qualifyingPointsAwarded ,
} )
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 00:32:35 -07:00
. 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
2026-07-01 22:14:51 +00:00
. select ( {
id : schema.eventResults.seasonParticipantId ,
qp : schema.eventResults.qualifyingPointsAwarded ,
} )
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 00:32:35 -07:00
. from ( schema . eventResults )
. where ( eq ( schema . eventResults . scoringEventId , eventId ) ) ;
2026-07-01 22:14:51 +00:00
const afterIds = new Set ( afterRows . map ( ( r ) = > r . id ) ) ;
for ( const { id } of beforeRows ) {
if ( ! afterIds . has ( id ) ) await recalculateParticipantQP ( id , sportsSeasonId , tx ) ;
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 00:32:35 -07:00
}
2026-07-01 22:14:51 +00:00
changed = diffChangedQualifyingPoints ( beforeRows , afterRows ) ;
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 00:32:35 -07:00
} ) ;
2026-07-01 22:14:51 +00:00
return changed ;
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 00:32:35 -07:00
}