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>
75 lines
2.5 KiB
TypeScript
75 lines
2.5 KiB
TypeScript
import { and, eq, isNotNull } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { requireCronSecret } from "~/lib/cron-auth";
|
|
import { syncMatches, syncTennisDraw } from "~/services/match-sync";
|
|
|
|
export async function action({ request }: { request: Request }) {
|
|
requireCronSecret(request);
|
|
|
|
const db = database();
|
|
const seasons = await db.query.sportsSeasons.findMany({
|
|
where: and(
|
|
isNotNull(schema.sportsSeasons.externalSeasonId),
|
|
eq(schema.sportsSeasons.status, "active")
|
|
),
|
|
with: { sport: true },
|
|
});
|
|
|
|
const synced: string[] = [];
|
|
const errors: { id: string; name: string; error: string }[] = [];
|
|
|
|
for (const season of seasons) {
|
|
try {
|
|
const result = await syncMatches(season.id);
|
|
synced.push(season.id);
|
|
if (result.errors.length > 0) {
|
|
for (const e of result.errors) {
|
|
errors.push({ id: season.id, name: season.name, error: e.error });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
errors.push({
|
|
id: season.id,
|
|
name: season.name,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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 },
|
|
);
|
|
}
|