Canonical tournament layer:

cutover + cleanup (2/2) (#367)

* refactor(schema): rename per-window tables to season_* prefix

Renames participants, participant_expected_values, participant_qualifying_totals,
participant_results, participant_surface_elos to season_* prefixed names.
Renames event_results.participant_id to season_participant_id.
Phase 1a of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: rename participant.ts model file to season-participant.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(models): update model layer to use renamed schema exports

Updated all model files to use the renamed schema exports from Task 1:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantQualifyingTotals → seasonParticipantQualifyingTotals
- participantResults → seasonParticipantResults
- participantSurfaceElos → seasonParticipantSurfaceElos
- eventResults.participantId → eventResults.seasonParticipantId
- db.query relation accessors updated
- Relation field .participant → .seasonParticipant where applicable
- Import paths updated: ./participant → ./season-participant

Files updated (14 model files + 3 test files):
- draft-pick.ts
- draft-utils.ts
- event-result.ts
- group-stage-match.ts
- participant-result.ts
- qualifying-points.ts
- scoring-calculator.ts
- scoring-event.ts
- sports-season.ts
- surface-elo.ts
- team-score-events.ts
- cs2-major-stage.ts
- golf-skills.ts
- participant-expected-value.ts
- __tests__/sports-season.clone.test.ts
- __tests__/auto-pick.test.ts
- __tests__/executeAutoPick.timer.test.ts

Typecheck errors decreased: 779 → 499 (280 fewer)
All model file errors related to renamed schemas resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route layer to use renamed schema exports

- Update model import from ~/models/participant to ~/models/season-participant
- Rename schema.participants to schema.seasonParticipants
- Rename schema.participantResults to schema.seasonParticipantResults
- Rename db.query.participants to db.query.seasonParticipants
- Update 9 route files and 1 test file

Affected files:
- admin.sports-seasons.$id.events.$eventId.bracket.server.ts
- admin.sports-seasons.$id.participants.tsx
- api/draft.force-manual-pick.ts
- api/draft.make-pick.ts
- api/draft.replace-pick.ts
- api/seasons.$seasonId.draft.ts
- leagues/$leagueId.draft-board.$seasonId.tsx
- leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts
- admin/__tests__/sports-seasons-participants.test.ts

Error count reduced from 499 to 453 (46 errors fixed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(routes): update route files for schema rename

Update route imports from ~/models/participant to ~/models/season-participant
and fix references to .participant/.participantId on event results to use
.seasonParticipant/.seasonParticipantId after schema rename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(services): update simulators and services for renamed schema

Update all simulators, services, and server files to use renamed schema tables:
- participants → seasonParticipants
- participantExpectedValues → seasonParticipantExpectedValues
- participantResults → seasonParticipantResults
- eventResults.participantId → eventResults.seasonParticipantId

Files updated:
- 20 sport simulators (NBA, NHL, NFL, MLB, etc.)
- probability-updater.ts
- standings-sync/index.ts
- sports-data-sync.server.ts
- server/socket.ts

Typecheck errors reduced from 365 to 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: rename per-window tables to season_* prefix

* fix(tests): update mock query keys after participants table rename

Change mock db.query.participants to db.query.seasonParticipants in test
files to match the schema rename from commit 66145a9. This fixes
"Cannot read properties of undefined (reading 'findFirst'/'findMany')"
errors that occurred when production code queries db.query.seasonParticipants
but test mocks only defined the old participants key.

Files updated:
- app/services/simulations/__tests__/world-cup-simulator.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.test.ts
- app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts
- app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts
- server/__tests__/timer-autodraft.test.ts
- app/models/__tests__/team-score-events.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update remaining mock paths and keys after schema rename

* fix(tests): final two mock stragglers after schema rename

- draft-pick.test.ts: assertion on db.query.participantQualifyingTotals
- process-match-result.test.ts: mock key participants → seasonParticipants

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add post-phase1a baseline capture (temp, for diff verification)

* chore: capture pre-migration baselines

* chore: remove post-phase1a capture helper after verification

* schema: add canonical tournament & participant tables

Adds tournaments, participants (canonical), tournament_results, and
participant_surface_elos (canonical). Adds nullable tournament_id to
scoring_events and nullable participant_id to season_participants.
Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(models): add canonical tournament, participant, result, surface-elo models

Adds CRUD modules for the canonical tables created in commit 775b905.
Each module mirrors existing app/models conventions (database() from
~/database/context, schema from ~/database/schema, mock-based tests).

Key implementation notes:
- participant.ts exports use "Canonical" prefix (CanonicalParticipant,
  createCanonicalParticipant, etc.) to avoid collision with existing
  season-participant.ts exports
- All four models include comprehensive unit tests following the
  audit-log.test.ts pattern
- Tests use mocked db responses (no real database access)
- Upsert functions use onConflictDoUpdate for appropriate unique constraints

Part of Phase 1b of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: create canonical tables, add nullable FKs

* scripts: add extractTournamentIdentity helper for backfill

Pure function that derives canonical (name, year) identity from a
scoring_events row, stripping trailing 4-digit years from the name or
falling back to eventDate. Used by the Phase 2 backfill to group
per-window events into canonical tournaments.

* scripts: add backfill orchestrator for canonical layer

Populates canonical tournaments, participants, tournament_results, and
participant_surface_elos from per-window data for qualifying-points
sports. Skips already-linked rows, is idempotent, and supports dry-run
mode.

Critical invariants enforced by the implementation:
- qualifying_points_awarded is never copied to tournament_results
- season_participant_qualifying_totals is never touched
- conflicting surface-Elo values between windows raise a loud error
  (recorded in report.errors) rather than overwriting

* scripts: add backfill CLI with dry-run default

Wires backfill-canonical-layer.ts to a CLI entry point exposed as
`npm run backfill:canonical`. Defaults to --dry-run; requires --apply
to actually write. Supports --sport=<uuid> to limit to a single sport.
Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts).

* fix(backfill-cli): wrap runBackfill in DatabaseContext.run

The orchestrator uses database() from ~/database/context, which requires
AsyncLocalStorage to be populated. Wrap the CLI invocation with
DatabaseContext.run(db, ...) using server/db's cached connection pool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backfill-cli): exit 0 on success so pg pool doesn't block

The cached postgres connection pool keeps the Node event loop open after
main() returns. Explicit process.exit(0) on success mirrors the pattern
in scripts/capture-baseline.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* schema: add check constraint requiring tournament_id for qualifying events

Team sports (NHL, NBA, etc.) have scoring_events with no canonical
tournament, so we can't require tournament_id globally. The constraint
only applies when is_qualifying_event=true.

Phase 3 Task 1 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(BatchResultEntry): make form intent configurable

Canonical tournament admin page will submit with a different intent
(batch-upsert-results). Existing per-window callers continue to get
"batch-add-results" as the default, so no behavior changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(services): syncTournamentResults fan-out service

* schema: add unique (scoring_event_id, season_participant_id) on event_results

syncTournamentResults uses check-then-write on event_results. The
unique index defends against race conditions (unlikely today — admin
is single-writer — but safe to enforce).

WARNING: if existing prod data has duplicate (scoring_event_id,
season_participant_id) pairs, the migration will fail. Verify with:
  SELECT scoring_event_id, season_participant_id, count(*)
  FROM event_results
  GROUP BY 1, 2
  HAVING count(*) > 1;

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(surface-elo): getSurfaceEloMap reads from canonical

Switch the tennis simulator's Elo source from the per-window table
to the canonical participant_surface_elos via season_participants.participant_id.
Map key stays season_participant.id so the simulator's call pattern is unchanged.

Other functions in this file still read/write the per-window table
(seasonParticipantSurfaceElos). They back the old admin surface-elo page
and are removed in Phase 4 after the canonical admin flow replaces them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(admin): canonical tournament list + result entry routes

New routes /admin/tournaments and /admin/tournaments/:id. The detail
page lets admins enter tournament results once via paste-and-parse,
then calls syncTournamentResults to fan out to every linked window.

Phase 3 Task 4 of canonical tournament layer migration.

* feat: per-window batch-add-results routes through canonical when linked

If the scoring_event has a tournament_id, translate season_participant ids
to canonical participant ids, upsert tournament_results, and fan out via
syncTournamentResults. Team sports (no tournament link) keep the legacy
direct-write path.

Phase 3 Task 8 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(surface-elo): mirror batchUpsert writes to canonical table

The simulator now reads surface Elo from the canonical table
(participant_surface_elos). The existing per-window admin page still
posts to batchUpsertSurfaceElos; without this change, those edits
would silently fail to affect simulator output. Mirror every write
to both tables until Phase 4 removes the per-window path entirely.

Phase 3 Task 5 (partial — canonical write path; dedicated canonical
admin UI deferred; existing page continues to work and now
edits both tables).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: auto-provision canonical tournaments and participants on create

Creating new qualifying scoring_events or season_participants now
auto-upserts the matching canonical rows. Needed so the check constraint
doesn't reject qualifying events, and so new season participants
flow through syncTournamentResults.

- Extract tournament identity shared between backfill and event-creation
  into app/lib/tournament-identity.ts; scripts/backfill re-exports it.
- createScoringEvent + bulkCreateScoringEvents now accept tournamentId.
- Event creation routes auto-compute (name, year) and upsert tournament.
- createParticipant + createManyParticipants auto-link to canonical
  participants by (sportId, name).

Phase 3 Task 7 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update surface-elo tests for canonical mirror

getSurfaceEloMap now joins through season_participants and returns
rows keyed by seasonParticipantId (was participantId).

batchUpsertSurfaceElos now performs a second db.select to resolve
canonical links before mirroring writes; tests mock the lookup
explicitly and exercise both the "no canonical link" short-circuit
path and the "mirror to canonical" path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: add check constraint + unique index for canonical

* refactor(surface-elo): canonical-only; drop per-window path

batchUpsertSurfaceElos now writes only to the canonical
participant_surface_elos table (the mirror step is now the only step).
getSurfaceElosForSeason joins through season_participants → canonical
participants so the admin UI keeps its existing (id, participantId,
sportsSeasonId, ...) row shape.

season_participants without a canonical link get silently skipped. In
practice every qualifying-points roster entry is canonical-linked after
Phase 2 backfill + Phase 3 auto-linking on createParticipant.

Phase 4 Tasks 2 + 3 of canonical tournament layer migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: remove legacy direct-write fallback in batch-add-results

Every qualifying scoring_event now has tournament_id (check constraint
enforces it), so the non-canonical fallback is dead. Replace with an
explicit error surface so any configuration regression becomes visible
instead of silently bypassing canonical.

Phase 4 Task 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* schema: drop season_participant_surface_elos table

Table had no remaining readers after Phase 3 and no remaining writers
after Phase 4 Task 2. Also removes the now-unrunnable Phase 2 backfill
scripts and the backfill:canonical npm script — the backfill ran once
and is preserved in git history (commits 85bca8b, 8186dbb, 6f3438b,
bc0f530, 327c7b9).

Phase 4 Task 5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(agents): describe canonical/per-window split

Update database.md with the canonical/per-window model explanation,
the syncTournamentResults fan-out contract, the check constraint,
and the tennis-Men/Women quirk.

Update domain-models.md to reflect the renamed per-window entities
and the new canonical layer.

Also update the drizzle-kit gotchas section with lessons from the
migration: do not use --custom for renames; FK constraint naming
can mismatch Drizzle convention.

Phase 4 Task 6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* migration: drop season_participant_surface_elos

* feat(admin): add Tournaments link to admin sidebar

Missed in Phase 3 Task 4 — the /admin/tournaments route exists but
was not reachable from the admin nav without typing the URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(lint): address 4 oxlint errors on canonical-layer-pr2

- Remove unused filterNewResults import (events batch-add-results legacy
  path was removed in Phase 4 Task 4).
- Replace import() type annotation in sync-tournament-results test with
  a top-level type import (consistent-type-imports rule).
- Hoist tableName helper out of makeFakeDb closure so it's not recreated
  per call (consistent-function-scoping rule).
- eqeqeq: replace `!= null` with explicit null/undefined checks in
  tournament-identity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Chris Parsons <chrisp@extrahop.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-01 21:04:48 -07:00 committed by GitHub
parent 2848231235
commit 48b1d470f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 13166 additions and 1112 deletions

View file

@ -46,12 +46,18 @@ interface BatchResultEntryProps {
participants: Participant[]; participants: Participant[];
sportsSeasonId: string; sportsSeasonId: string;
existingResultParticipantIds: Set<string>; existingResultParticipantIds: Set<string>;
/**
* Form intent name posted on "Save All". Defaults to "batch-add-results" for
* the existing per-window flow. Canonical tournament pages override this.
*/
intent?: string;
} }
export function BatchResultEntry({ export function BatchResultEntry({
participants, participants,
sportsSeasonId, sportsSeasonId,
existingResultParticipantIds, existingResultParticipantIds,
intent = "batch-add-results",
}: BatchResultEntryProps) { }: BatchResultEntryProps) {
const [stage, setStage] = useState<Stage>("idle"); const [stage, setStage] = useState<Stage>("idle");
const [pasteText, setPasteText] = useState(""); const [pasteText, setPasteText] = useState("");
@ -172,7 +178,7 @@ export function BatchResultEntry({
placement: r.placement, placement: r.placement,
})); }));
const formData = new FormData(); const formData = new FormData();
formData.set("intent", "batch-add-results"); formData.set("intent", intent);
formData.set("results", JSON.stringify(results)); formData.set("results", JSON.stringify(results));
saveFetcher.submit(formData, { method: "post" }); saveFetcher.submit(formData, { method: "post" });
} }

View file

@ -0,0 +1,50 @@
/**
* Extracts the canonical tournament identity `(name, year)` from a scoring
* event. Used both by the Phase 2 backfill and by the admin create-event flow
* that auto-provisions a canonical tournament for new qualifying events.
*
* Rules:
* - If the event name ends with a 4-digit year, strip it; that year wins.
* - Otherwise, fall back to the year portion of eventDate (YYYY-MM-DD).
* - If neither produces a year, throw.
*/
export interface ScoringEventIdentityInput {
name: string;
eventDate: string | Date | null | undefined;
eventType: string;
}
export interface TournamentIdentity {
name: string;
year: number;
}
const YEAR_SUFFIX = /\s+(\d{4})\s*$/;
export function extractTournamentIdentity(ev: ScoringEventIdentityInput): TournamentIdentity {
const trimmed = ev.name.trim();
const yearMatch = trimmed.match(YEAR_SUFFIX);
let cleanName = trimmed;
let year: number | null = null;
if (yearMatch) {
year = parseInt(yearMatch[1], 10);
cleanName = trimmed.replace(YEAR_SUFFIX, "").trim();
}
if (year === null && ev.eventDate !== null && ev.eventDate !== undefined) {
const iso = ev.eventDate instanceof Date
? ev.eventDate.toISOString()
: String(ev.eventDate);
year = parseInt(iso.slice(0, 4), 10);
}
if (year === null || Number.isNaN(year)) {
throw new Error(
`cannot determine year for event "${ev.name}" — no year in name and eventDate is null`,
);
}
return { name: cleanName, year };
}

View file

@ -42,10 +42,10 @@ describe("getSurfaceEloMap", () => {
expect(result.size).toBe(0); expect(result.size).toBe(0);
}); });
it("returns a Map keyed by participantId", async () => { it("returns a Map keyed by seasonParticipantId", async () => {
mockDb.where.mockResolvedValue([ mockDb.where.mockResolvedValue([
{ participantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, { seasonParticipantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
{ participantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null }, { seasonParticipantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null },
]); ]);
const result = await getSurfaceEloMap("season-1"); const result = await getSurfaceEloMap("season-1");
expect(result.size).toBe(2); expect(result.size).toBe(2);
@ -55,7 +55,7 @@ describe("getSurfaceEloMap", () => {
it("preserves null surface Elos (does not coerce to 0)", async () => { it("preserves null surface Elos (does not coerce to 0)", async () => {
mockDb.where.mockResolvedValue([ mockDb.where.mockResolvedValue([
{ participantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null }, { seasonParticipantId: "p1", worldRanking: null, eloHard: null, eloClay: null, eloGrass: null },
]); ]);
const result = await getSurfaceEloMap("season-1"); const result = await getSurfaceEloMap("season-1");
const entry = result.get("p1"); const entry = result.get("p1");
@ -72,14 +72,21 @@ describe("batchUpsertSurfaceElos", () => {
expect(mockDb.insert).not.toHaveBeenCalled(); expect(mockDb.insert).not.toHaveBeenCalled();
}); });
it("calls insert with the correct values", async () => { it("writes canonical rows using resolved canonical participant ids", async () => {
const inputs = [ // season_participants lookup returns canonical links for p1 and p2.
mockDb.where.mockResolvedValue([
{ id: "p1", canonicalId: "canon-1" },
{ id: "p2", canonicalId: "canon-2" },
]);
await batchUpsertSurfaceElos([
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, { participantId: "p1", sportsSeasonId: "s1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 },
{ participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined }, { participantId: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined },
]; ]);
await batchUpsertSurfaceElos(inputs);
expect(mockDb.insert).toHaveBeenCalledOnce(); expect(mockDb.insert).toHaveBeenCalledOnce();
expect(mockDb.values).toHaveBeenCalledOnce(); expect(mockDb.values).toHaveBeenCalledOnce();
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
const passedValues = mockDb.values.mock.calls[0][0] as Array<{ const passedValues = mockDb.values.mock.calls[0][0] as Array<{
participantId: string; participantId: string;
@ -88,17 +95,21 @@ describe("batchUpsertSurfaceElos", () => {
eloGrass: number | null; eloGrass: number | null;
}>; }>;
expect(passedValues).toHaveLength(2); expect(passedValues).toHaveLength(2);
expect(passedValues[0].participantId).toBe("p1"); expect(passedValues[0].participantId).toBe("canon-1");
expect(passedValues[0].eloHard).toBe(1800); expect(passedValues[0].eloHard).toBe(1800);
expect(passedValues[1].participantId).toBe("canon-2");
expect(passedValues[1].eloHard).toBeNull(); expect(passedValues[1].eloHard).toBeNull();
expect(passedValues[1].eloGrass).toBeNull(); // undefined → null expect(passedValues[1].eloGrass).toBeNull(); // undefined → null
}); });
it("uses onConflictDoUpdate for upsert behaviour", async () => { it("skips season_participants that have no canonical link", async () => {
mockDb.where.mockResolvedValue([]); // no canonical links; short-circuits.
await batchUpsertSurfaceElos([ await batchUpsertSurfaceElos([
{ participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 }, { participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 },
]); ]);
expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce();
expect(mockDb.insert).not.toHaveBeenCalled();
}); });
}); });

View file

@ -24,6 +24,8 @@ export interface CreateScoringEventData {
eventStartsAt?: Date; eventStartsAt?: Date;
eventType: EventType; eventType: EventType;
isQualifyingEvent?: boolean; isQualifyingEvent?: boolean;
/** Required when isQualifyingEvent=true (DB check constraint). */
tournamentId?: string;
// Template system fields (Phase 2.6) // Template system fields (Phase 2.6)
bracketTemplateId?: string; bracketTemplateId?: string;
scoringStartsAtRound?: string; scoringStartsAtRound?: string;
@ -59,6 +61,7 @@ export async function createScoringEvent(
eventStartsAt: data.eventStartsAt, eventStartsAt: data.eventStartsAt,
eventType: data.eventType, eventType: data.eventType,
isQualifyingEvent: data.isQualifyingEvent || false, isQualifyingEvent: data.isQualifyingEvent || false,
tournamentId: data.tournamentId,
bracketTemplateId: data.bracketTemplateId, bracketTemplateId: data.bracketTemplateId,
scoringStartsAtRound: data.scoringStartsAtRound, scoringStartsAtRound: data.scoringStartsAtRound,
isComplete: false, isComplete: false,
@ -743,7 +746,7 @@ export async function getEventsForDates(
*/ */
export async function bulkCreateScoringEvents( export async function bulkCreateScoringEvents(
sportsSeasonId: string, sportsSeasonId: string,
events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean }>, events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; tournamentId?: string }>,
providedDb?: ReturnType<typeof database> providedDb?: ReturnType<typeof database>
) { ) {
const db = providedDb || database(); const db = providedDb || database();
@ -757,6 +760,7 @@ export async function bulkCreateScoringEvents(
eventStartsAt: e.eventStartsAt, eventStartsAt: e.eventStartsAt,
eventType: e.eventType, eventType: e.eventType,
isQualifyingEvent: e.isQualifyingEvent ?? false, isQualifyingEvent: e.isQualifyingEvent ?? false,
tournamentId: e.tournamentId,
isComplete: false, isComplete: false,
})); }));

View file

@ -7,18 +7,86 @@ export type NewParticipant = typeof schema.seasonParticipants.$inferInsert;
export async function createParticipant(data: NewParticipant): Promise<Participant> { export async function createParticipant(data: NewParticipant): Promise<Participant> {
const db = database(); const db = database();
// Auto-link to canonical participant (by sportId + name) if caller didn't
// supply one. Needed so syncTournamentResults can match this roster entry
// to canonical tournament_results; without the link the participant is
// invisible to the canonical layer.
let canonicalId = data.participantId;
if (!canonicalId) {
const season = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, data.sportsSeasonId),
columns: { sportId: true },
});
if (season) {
const existing = await db.query.participants.findFirst({
where: and(
eq(schema.participants.sportId, season.sportId),
eq(schema.participants.name, data.name),
),
});
if (existing) {
canonicalId = existing.id;
} else {
const [created] = await db
.insert(schema.participants)
.values({ sportId: season.sportId, name: data.name })
.returning();
canonicalId = created.id;
}
}
}
const [participant] = await db const [participant] = await db
.insert(schema.seasonParticipants) .insert(schema.seasonParticipants)
.values(data) .values({ ...data, participantId: canonicalId })
.returning(); .returning();
return participant; return participant;
} }
export async function createManyParticipants(data: NewParticipant[]): Promise<Participant[]> { export async function createManyParticipants(data: NewParticipant[]): Promise<Participant[]> {
const db = database(); const db = database();
if (data.length === 0) return [];
// Build canonical links per row, just like createParticipant does.
const seasonIds = Array.from(new Set(data.map((d) => d.sportsSeasonId)));
const seasons = await db.query.sportsSeasons.findMany({
where: inArray(schema.sportsSeasons.id, seasonIds),
columns: { id: true, sportId: true },
});
const sportBySeason = new Map(seasons.map((s) => [s.id, s.sportId]));
const enriched: NewParticipant[] = [];
for (const row of data) {
if (row.participantId) {
enriched.push(row);
continue;
}
const sportId = sportBySeason.get(row.sportsSeasonId);
if (!sportId) {
enriched.push(row);
continue;
}
const existing = await db.query.participants.findFirst({
where: and(
eq(schema.participants.sportId, sportId),
eq(schema.participants.name, row.name),
),
});
let canonicalId = existing?.id;
if (!canonicalId) {
const [created] = await db
.insert(schema.participants)
.values({ sportId, name: row.name })
.returning();
canonicalId = created.id;
}
enriched.push({ ...row, participantId: canonicalId });
}
return await db return await db
.insert(schema.seasonParticipants) .insert(schema.seasonParticipants)
.values(data) .values(enriched)
.returning(); .returning();
} }

View file

@ -1,14 +1,18 @@
/** /**
* Model for Participant Surface Elos * Surface Elo model (canonical).
* *
* Manages surface-specific Elo ratings for tennis (and future surface-based sports). * Surface Elos are stored in the canonical `participant_surface_elos` table,
* Each participant in a sports season can have separate Elo ratings for hard, * keyed by canonical participant id. The admin UI still works in terms of
* clay, and grass courts. * season_participants we join through season_participants canonical
* participant canonical surface Elo so the UI doesn't need to know about
* the canonical layer.
*
* See `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md`.
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
import { seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema"; import { participantSurfaceElos, seasonParticipants } from "~/database/schema";
import { eq, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
export type CourtSurface = "hard" | "clay" | "grass"; export type CourtSurface = "hard" | "clay" | "grass";
@ -37,60 +41,82 @@ export interface SurfaceEloInput {
} }
/** /**
* Get all surface Elo records for a sports season, joined with participant names. * Load surface Elos for a sports season's roster, joined with the per-window
* Returns one record per participant (seasonParticipants with no Elo record are excluded). * participant's name. Internally joins season_participants canonical
* participants canonical participant_surface_elos.
*
* The returned `id` is the canonical `participant_surface_elos.id`;
* `participantId` is the season_participant id (admin UI keys rows by that).
*/ */
export async function getSurfaceElosForSeason( export async function getSurfaceElosForSeason(
sportsSeasonId: string sportsSeasonId: string,
): Promise<SurfaceEloWithName[]> { ): Promise<SurfaceEloWithName[]> {
const db = database(); const db = database();
const rows = await db return await db
.select({ .select({
id: seasonParticipantSurfaceElos.id, id: participantSurfaceElos.id,
participantId: seasonParticipantSurfaceElos.participantId, participantId: seasonParticipants.id,
sportsSeasonId: seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId: seasonParticipants.sportsSeasonId,
worldRanking: seasonParticipantSurfaceElos.worldRanking, worldRanking: participantSurfaceElos.worldRanking,
eloHard: seasonParticipantSurfaceElos.eloHard, eloHard: participantSurfaceElos.eloHard,
eloClay: seasonParticipantSurfaceElos.eloClay, eloClay: participantSurfaceElos.eloClay,
eloGrass: seasonParticipantSurfaceElos.eloGrass, eloGrass: participantSurfaceElos.eloGrass,
updatedAt: seasonParticipantSurfaceElos.updatedAt, updatedAt: participantSurfaceElos.updatedAt,
participantName: seasonParticipants.name, participantName: seasonParticipants.name,
}) })
.from(seasonParticipantSurfaceElos) .from(seasonParticipants)
.innerJoin(seasonParticipants, eq(seasonParticipantSurfaceElos.participantId, seasonParticipants.id)) .innerJoin(
.where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId)) participantSurfaceElos,
eq(participantSurfaceElos.participantId, seasonParticipants.participantId),
)
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId))
.orderBy(seasonParticipants.name); .orderBy(seasonParticipants.name);
return rows;
} }
/** /**
* Upsert surface Elo ratings for a batch of seasonParticipants. * Upsert surface Elos for a batch of season_participants. Writes to the
* Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are * canonical `participant_surface_elos` table; callers pass season_participant
* overwritten atomically the admin always submits all three values. * ids and we resolve canonical ids internally.
*
* season_participants without a canonical link are silently skipped. In
* practice every qualifying-points roster entry is canonical-linked after
* Phase 2 + the Phase 3 auto-linking on createParticipant.
*/ */
export async function batchUpsertSurfaceElos( export async function batchUpsertSurfaceElos(
inputs: SurfaceEloInput[] inputs: SurfaceEloInput[],
): Promise<void> { ): Promise<void> {
if (inputs.length === 0) return; if (inputs.length === 0) return;
const db = database(); const db = database();
const now = new Date(); const now = new Date();
// Resolve canonical ids for every season_participant in the batch.
const sps = await db
.select({ id: seasonParticipants.id, canonicalId: seasonParticipants.participantId })
.from(seasonParticipants)
.where(inArray(seasonParticipants.id, inputs.map((i) => i.participantId)));
const canonicalByInputId = new Map(sps.map((sp) => [sp.id, sp.canonicalId]));
const canonicalRows = inputs
.map((i) => ({
canonicalId: canonicalByInputId.get(i.participantId),
worldRanking: i.worldRanking ?? null,
eloHard: i.eloHard ?? null,
eloClay: i.eloClay ?? null,
eloGrass: i.eloGrass ?? null,
}))
.filter((r): r is typeof r & { canonicalId: string } => typeof r.canonicalId === "string");
if (canonicalRows.length === 0) return;
await db await db
.insert(seasonParticipantSurfaceElos) .insert(participantSurfaceElos)
.values( .values(canonicalRows.map(({ canonicalId, ...rest }) => ({
inputs.map(({ participantId, sportsSeasonId, worldRanking, eloHard, eloClay, eloGrass }) => ({ participantId: canonicalId,
participantId, ...rest,
sportsSeasonId, updatedAt: now,
worldRanking: worldRanking ?? null, })))
eloHard: eloHard ?? null,
eloClay: eloClay ?? null,
eloGrass: eloGrass ?? null,
updatedAt: now,
}))
)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [seasonParticipantSurfaceElos.participantId, seasonParticipantSurfaceElos.sportsSeasonId], target: [participantSurfaceElos.participantId],
set: { set: {
worldRanking: sql`excluded.world_ranking`, worldRanking: sql`excluded.world_ranking`,
eloHard: sql`excluded.elo_hard`, eloHard: sql`excluded.elo_hard`,
@ -102,25 +128,34 @@ export async function batchUpsertSurfaceElos(
} }
/** /**
* Returns a Map from participantId to surface Elos for use in the simulator. * Build a map keyed by season_participant.id surface Elo values, sourced
* Participants with no record are absent from the map (simulator falls back to 1500). * from the canonical `participant_surface_elos` table. Used by the tennis
* simulator.
*
* Season participants without a linked canonical participant, or with no
* canonical surface Elo row, are absent from the map callers handle
* `undefined` by falling back to 1500 (the simulator's default).
*/ */
export async function getSurfaceEloMap( export async function getSurfaceEloMap(
sportsSeasonId: string sportsSeasonId: string,
): Promise<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> { ): Promise<Map<string, { worldRanking: number | null; eloHard: number | null; eloClay: number | null; eloGrass: number | null }>> {
const db = database(); const db = database();
const rows = await db const rows = await db
.select({ .select({
participantId: seasonParticipantSurfaceElos.participantId, seasonParticipantId: seasonParticipants.id,
worldRanking: seasonParticipantSurfaceElos.worldRanking, worldRanking: participantSurfaceElos.worldRanking,
eloHard: seasonParticipantSurfaceElos.eloHard, eloHard: participantSurfaceElos.eloHard,
eloClay: seasonParticipantSurfaceElos.eloClay, eloClay: participantSurfaceElos.eloClay,
eloGrass: seasonParticipantSurfaceElos.eloGrass, eloGrass: participantSurfaceElos.eloGrass,
}) })
.from(seasonParticipantSurfaceElos) .from(seasonParticipants)
.where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId)); .innerJoin(
participantSurfaceElos,
eq(participantSurfaceElos.participantId, seasonParticipants.participantId),
)
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId));
return new Map(rows.map((r) => [r.participantId, { return new Map(rows.map((r) => [r.seasonParticipantId, {
worldRanking: r.worldRanking, worldRanking: r.worldRanking,
eloHard: r.eloHard, eloHard: r.eloHard,
eloClay: r.eloClay, eloClay: r.eloClay,

View file

@ -1,4 +1,4 @@
import { eq, and, asc } from "drizzle-orm"; import { eq, and, asc, desc } 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";
@ -80,6 +80,50 @@ export async function upsertTournament(
return await createTournament(data); return await createTournament(data);
} }
export async function findAllTournamentsGroupedBySport(): Promise<
Array<{
sport: { id: string; name: string };
tournaments: Tournament[];
}>
> {
const db = database();
const rows = await db
.select({
tournament: schema.tournaments,
sport: {
id: schema.sports.id,
name: schema.sports.name,
},
})
.from(schema.tournaments)
.innerJoin(schema.sports, eq(schema.tournaments.sportId, schema.sports.id))
.orderBy(
asc(schema.sports.name),
desc(schema.tournaments.year),
asc(schema.tournaments.startsAt)
);
const groupMap = new Map<
string,
{ sport: { id: string; name: string }; tournaments: Tournament[] }
>();
for (const row of rows) {
const existing = groupMap.get(row.sport.id);
if (existing) {
existing.tournaments.push(row.tournament);
} else {
groupMap.set(row.sport.id, {
sport: row.sport,
tournaments: [row.tournament],
});
}
}
return Array.from(groupMap.values());
}
export async function updateTournamentStatus( export async function updateTournamentStatus(
id: string, id: string,
status: TournamentStatus status: TournamentStatus

View file

@ -129,6 +129,8 @@ export default [
"routes/admin.sports-seasons.$id.clone.tsx" "routes/admin.sports-seasons.$id.clone.tsx"
), ),
route("participants", "routes/admin.participants.tsx"), route("participants", "routes/admin.participants.tsx"),
route("tournaments", "routes/admin.tournaments._index.tsx"),
route("tournaments/:id", "routes/admin.tournaments.$id.tsx"),
route("templates", "routes/admin.templates.tsx"), route("templates", "routes/admin.templates.tsx"),
route("templates/new", "routes/admin.templates.new.tsx"), route("templates/new", "routes/admin.templates.new.tsx"),
route("templates/:id", "routes/admin.templates.$id.tsx"), route("templates/:id", "routes/admin.templates.$id.tsx"),

View file

@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/lib/auth.server", () => ({
auth: { api: { getSession: vi.fn() } },
}));
vi.mock("~/models/user", () => ({
isUserAdmin: vi.fn(),
}));
vi.mock("~/models/tournament", () => ({
getTournamentById: vi.fn(),
updateTournamentStatus: vi.fn(),
}));
vi.mock("~/models/tournament-result", () => ({
getTournamentResults: vi.fn(),
upsertTournamentResult: vi.fn(),
}));
vi.mock("~/models/participant", () => ({
findCanonicalParticipantsBySport: vi.fn(),
}));
vi.mock("~/services/sync-tournament-results", () => ({
syncTournamentResults: vi.fn(),
}));
vi.mock("~/lib/logger", () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
import { action } from "~/routes/admin.tournaments.$id";
import { auth } from "~/lib/auth.server";
import { isUserAdmin } from "~/models/user";
import {
getTournamentById,
updateTournamentStatus,
} from "~/models/tournament";
import { upsertTournamentResult } from "~/models/tournament-result";
import { syncTournamentResults } from "~/services/sync-tournament-results";
const TOURNAMENT_ID = "tournament-1";
const ADMIN_USER_ID = "admin-1";
const NON_ADMIN_USER_ID = "user-1";
function makeRequest(body: Record<string, string>) {
const formData = new FormData();
for (const [k, v] of Object.entries(body)) {
formData.append(k, v);
}
return new Request(`http://localhost/admin/tournaments/${TOURNAMENT_ID}`, {
method: "POST",
body: formData,
});
}
function makeActionArgs(request: Request) {
return {
request,
params: { id: TOURNAMENT_ID },
context: {} as never,
} as unknown as Parameters<typeof action>[0];
}
describe("admin.tournaments.$id action", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("batch-upsert-results: upserts each row, marks tournament completed, and fans out", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: ADMIN_USER_ID },
} as never);
vi.mocked(isUserAdmin).mockResolvedValue(true);
vi.mocked(getTournamentById).mockResolvedValue({
id: TOURNAMENT_ID,
sportId: "sport-1",
name: "US Open",
year: 2026,
status: "scheduled",
} as never);
vi.mocked(upsertTournamentResult).mockResolvedValue({} as never);
vi.mocked(updateTournamentStatus).mockResolvedValue({} as never);
const syncReport = {
tournamentId: TOURNAMENT_ID,
windowsSynced: 2,
windowsFailed: 0,
failures: [],
};
vi.mocked(syncTournamentResults).mockResolvedValue(syncReport);
const results = [
{ participantId: "p-1", placement: 1 },
{ participantId: "p-2", placement: 2 },
{ participantId: "p-3", placement: 3 },
];
const request = makeRequest({
intent: "batch-upsert-results",
results: JSON.stringify(results),
});
const response = await action(makeActionArgs(request));
expect(upsertTournamentResult).toHaveBeenCalledTimes(3);
expect(upsertTournamentResult).toHaveBeenCalledWith({
tournamentId: TOURNAMENT_ID,
participantId: "p-1",
placement: 1,
});
expect(updateTournamentStatus).toHaveBeenCalledWith(
TOURNAMENT_ID,
"completed"
);
expect(syncTournamentResults).toHaveBeenCalledWith(TOURNAMENT_ID);
expect(response).toMatchObject({
success: true,
syncReport,
});
});
it("does not re-update status when tournament is already completed", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: ADMIN_USER_ID },
} as never);
vi.mocked(isUserAdmin).mockResolvedValue(true);
vi.mocked(getTournamentById).mockResolvedValue({
id: TOURNAMENT_ID,
sportId: "sport-1",
name: "US Open",
year: 2026,
status: "completed",
} as never);
vi.mocked(upsertTournamentResult).mockResolvedValue({} as never);
vi.mocked(syncTournamentResults).mockResolvedValue({
tournamentId: TOURNAMENT_ID,
windowsSynced: 1,
windowsFailed: 0,
failures: [],
});
const request = makeRequest({
intent: "batch-upsert-results",
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
});
await action(makeActionArgs(request));
expect(updateTournamentStatus).not.toHaveBeenCalled();
expect(syncTournamentResults).toHaveBeenCalledWith(TOURNAMENT_ID);
});
it("non-admin user is forbidden (403)", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: NON_ADMIN_USER_ID },
} as never);
vi.mocked(isUserAdmin).mockResolvedValue(false);
const request = makeRequest({
intent: "batch-upsert-results",
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
});
await expect(action(makeActionArgs(request))).rejects.toMatchObject({
status: 403,
});
expect(upsertTournamentResult).not.toHaveBeenCalled();
expect(syncTournamentResults).not.toHaveBeenCalled();
});
it("missing tournament returns 404", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue({
user: { id: ADMIN_USER_ID },
} as never);
vi.mocked(isUserAdmin).mockResolvedValue(true);
vi.mocked(getTournamentById).mockResolvedValue(null);
const request = makeRequest({
intent: "batch-upsert-results",
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
});
await expect(action(makeActionArgs(request))).rejects.toMatchObject({
status: 404,
});
});
});

View file

@ -16,7 +16,7 @@ import {
type CreateEventResultData, type CreateEventResultData,
type UpdateEventResultData, type UpdateEventResultData,
} from "~/models/event-result"; } from "~/models/event-result";
import { filterNewResults, findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers"; import { findParticipantsWithoutResults } from "./admin.sports-seasons.$id.events.$eventId.helpers";
import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
import { import {
upsertParticipantSeasonResult, upsertParticipantSeasonResult,
@ -27,6 +27,8 @@ import { getQPStandings, getQPConfig } from "~/models/qualifying-points";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { upsertTournamentResult } from "~/models/tournament-result";
import { syncTournamentResults } from "~/services/sync-tournament-results";
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);
@ -372,22 +374,49 @@ export async function action({ request, params }: Route.ActionArgs) {
} }
try { try {
const existingResults = await getEventResults(params.eventId); // If this scoring event is linked to a canonical tournament, write
const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId)); // results to the canonical layer and fan out via syncTournamentResults.
const newResults = filterNewResults(incoming, existingIds); // This keeps sibling windows (if any) in sync automatically.
const scoringEvent = await database().query.scoringEvents.findFirst({
if (newResults.length > 0) { where: eq(schema.scoringEvents.id, params.eventId),
await createEventResultsBulk( });
newResults.map((r) => ({ if (!scoringEvent) {
scoringEventId: params.eventId, return { error: "Scoring event not found" };
participantId: r.participantId,
placement: r.placement,
}))
);
} }
// Qualifying events always have a canonical tournament link (check
// constraint). Write results canonically and fan out.
if (!scoringEvent.tournamentId) {
return {
error:
"This scoring event has no canonical tournament link — cannot save batch results. Contact a developer.",
};
}
const spRows = allSeasonParticipants.filter((sp) =>
incoming.some((r) => r.participantId === sp.id),
);
const missingCanonical = spRows.filter((sp) => !sp.participantId);
if (missingCanonical.length > 0) {
return {
error: `Some season participants are not linked to canonical participants: ${missingCanonical.map((sp) => sp.name).join(", ")}`,
};
}
for (const row of incoming) {
const sp = spRows.find((s) => s.id === row.participantId);
if (!sp?.participantId) continue;
await upsertTournamentResult({
tournamentId: scoringEvent.tournamentId,
participantId: sp.participantId,
placement: row.placement,
});
}
const syncReport = await syncTournamentResults(scoringEvent.tournamentId);
return { return {
success: `${newResults.length} result${newResults.length === 1 ? "" : "s"} saved${incoming.length - newResults.length > 0 ? ` (${incoming.length - newResults.length} duplicate${incoming.length - newResults.length === 1 ? "" : "s"} skipped)` : ""}.`, success: `${incoming.length} result${incoming.length === 1 ? "" : "s"} saved canonically. Synced to ${syncReport.windowsSynced} window${syncReport.windowsSynced === 1 ? "" : "s"}${syncReport.windowsFailed > 0 ? ` (${syncReport.windowsFailed} failed)` : ""}.`,
syncReport,
}; };
} catch (error) { } catch (error) {
logger.error("Error saving batch results:", error); logger.error("Error saving batch results:", error);

View file

@ -11,6 +11,8 @@ import {
} from "~/models/scoring-event"; } from "~/models/scoring-event";
import { getQPStandings } from "~/models/qualifying-points"; import { getQPStandings } from "~/models/qualifying-points";
import { finalizeQualifyingPoints } from "~/models/scoring-calculator"; import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
import { upsertTournament } from "~/models/tournament";
import { extractTournamentIdentity } from "~/lib/tournament-identity";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
@ -107,6 +109,31 @@ export async function action({ request, params }: Route.ActionArgs) {
return { error: "No valid events found. Use format: Event Name, YYYY-MM-DD" }; return { error: "No valid events found. Use format: Event Name, YYYY-MM-DD" };
} }
// Auto-provision canonical tournaments for qualifying events (check constraint).
try {
for (const ev of validEvents) {
if (!ev.isQualifyingEvent) continue;
const identity = extractTournamentIdentity({
name: ev.name,
eventDate: ev.eventDate ? ev.eventDate.toISOString().slice(0, 10) : null,
eventType: ev.eventType,
});
const tournament = await upsertTournament({
sportId: sportsSeason.sportId,
name: identity.name,
year: identity.year,
startsAt: ev.eventStartsAt ?? null,
});
(ev as typeof ev & { tournamentId: string }).tournamentId = tournament.id;
}
} catch (err) {
logger.error("Error linking qualifying events to canonical tournaments:", err);
return {
error:
"Could not determine tournament identity for one or more qualifying events. Include a 4-digit year or date for each.",
};
}
try { try {
await bulkCreateScoringEvents(params.id, validEvents); await bulkCreateScoringEvents(params.id, validEvents);
return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` }; return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` };
@ -178,6 +205,31 @@ export async function action({ request, params }: Route.ActionArgs) {
isQualifyingEvent, isQualifyingEvent,
}; };
// Qualifying events require a canonical tournament_id (check constraint).
// Auto-provision one by extracting (name, year) from the event name/date.
if (isQualifyingEvent) {
try {
const identity = extractTournamentIdentity({
name: eventData.name,
eventDate: eventDate ? eventDate.toISOString().slice(0, 10) : null,
eventType,
});
const tournament = await upsertTournament({
sportId: sportsSeason.sportId,
name: identity.name,
year: identity.year,
startsAt: eventStartsAt ?? null,
});
eventData.tournamentId = tournament.id;
} catch (err) {
logger.error("Error linking qualifying event to canonical tournament:", err);
return {
error:
"Could not determine tournament identity. Include a 4-digit year in the event name, or set a date.",
};
}
}
try { try {
const event = await createScoringEvent(eventData); const event = await createScoringEvent(eventData);
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`); return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);

View file

@ -0,0 +1,353 @@
import { Link, useFetcher } from "react-router";
import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/admin.tournaments.$id";
import { logger } from "~/lib/logger";
import {
getTournamentById,
updateTournamentStatus,
} from "~/models/tournament";
import {
getTournamentResults,
upsertTournamentResult,
} from "~/models/tournament-result";
import { findCanonicalParticipantsBySport } from "~/models/participant";
import { isUserAdmin } from "~/models/user";
import {
syncTournamentResults,
type SyncReport,
} from "~/services/sync-tournament-results";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { ArrowLeft, CheckCircle2, AlertTriangle } from "lucide-react";
import { BatchResultEntry } from "~/components/BatchResultEntry";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [
{
title: `${data?.tournament?.name ?? "Tournament"} - Brackt Admin`,
},
];
}
export async function loader({ params }: Route.LoaderArgs) {
const tournament = await getTournamentById(params.id);
if (!tournament) {
throw new Response("Not Found", { status: 404 });
}
const [results, canonicalParticipants] = await Promise.all([
getTournamentResults(tournament.id),
findCanonicalParticipantsBySport(tournament.sportId),
]);
return { tournament, results, canonicalParticipants };
}
export async function action(args: Route.ActionArgs) {
const { request, params } = args;
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null;
const isAdmin = userId ? await isUserAdmin(userId) : false;
if (!isAdmin) {
throw new Response("Forbidden", { status: 403 });
}
const tournament = await getTournamentById(params.id);
if (!tournament) {
throw new Response("Not Found", { status: 404 });
}
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "batch-upsert-results") {
const resultsRaw = formData.get("results");
if (typeof resultsRaw !== "string" || !resultsRaw) {
return {
success: false as const,
error: "Missing results payload",
syncReport: null,
};
}
let parsed: Array<{ participantId: string; placement: number }>;
try {
parsed = JSON.parse(resultsRaw);
} catch {
return {
success: false as const,
error: "Invalid JSON in results payload",
syncReport: null,
};
}
if (!Array.isArray(parsed)) {
return {
success: false as const,
error: "Results payload must be an array",
syncReport: null,
};
}
try {
for (const row of parsed) {
if (
!row ||
typeof row.participantId !== "string" ||
typeof row.placement !== "number"
) {
return {
success: false as const,
error: "Each result must have participantId and placement",
syncReport: null,
};
}
await upsertTournamentResult({
tournamentId: tournament.id,
participantId: row.participantId,
placement: row.placement,
});
}
if (tournament.status !== "completed") {
await updateTournamentStatus(tournament.id, "completed");
}
const syncReport = await syncTournamentResults(tournament.id);
return {
success: true as const,
error: null,
syncReport,
};
} catch (error) {
logger.error("batch-upsert-results failed:", error);
return {
success: false as const,
error:
error instanceof Error ? error.message : "Failed to save results",
syncReport: null,
};
}
}
if (intent === "retry-window-sync") {
try {
const syncReport = await syncTournamentResults(tournament.id);
return { success: true as const, error: null, syncReport };
} catch (error) {
logger.error("retry-window-sync failed:", error);
return {
success: false as const,
error:
error instanceof Error ? error.message : "Failed to retry sync",
syncReport: null,
};
}
}
return {
success: false as const,
error: "Invalid intent",
syncReport: null,
};
}
export default function AdminTournamentDetail({
loaderData,
actionData,
}: Route.ComponentProps) {
const { tournament, results, canonicalParticipants } = loaderData;
const retryFetcher = useFetcher<typeof action>();
// Prefer the latest action/retry response for the sync report
const liveReport: SyncReport | null =
(retryFetcher.data?.syncReport ?? actionData?.syncReport) ?? null;
const participantById = new Map(
canonicalParticipants.map((p) => [p.id, p])
);
return (
<div className="p-8">
<div className="max-w-5xl">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-2">
<Link to="/admin/tournaments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to tournaments
</Link>
</Button>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{tournament.name}</h1>
<Badge
variant={
tournament.status === "completed"
? "default"
: tournament.status === "in_progress"
? "secondary"
: "outline"
}
>
{tournament.status}
</Badge>
</div>
<p className="text-muted-foreground mt-1">
{tournament.year}
{tournament.location ? `${tournament.location}` : ""}
{tournament.surface ? `${tournament.surface}` : ""}
</p>
</div>
{liveReport && (
<Card className="mb-6 border-emerald-500/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-500">
<CheckCircle2 className="h-5 w-5" />
Synced to {liveReport.windowsSynced}{" "}
{liveReport.windowsSynced === 1 ? "window" : "windows"}
</CardTitle>
<CardDescription>
Canonical results were fanned out to every linked scoring
window.
</CardDescription>
</CardHeader>
{liveReport.failures.length > 0 && (
<CardContent>
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 space-y-3">
<div className="flex items-center gap-2 text-destructive font-medium">
<AlertTriangle className="h-4 w-4" />
{liveReport.failures.length}{" "}
{liveReport.failures.length === 1 ? "window" : "windows"}{" "}
failed to sync
</div>
<div className="space-y-2">
{liveReport.failures.map((f) => (
<div
key={f.scoringEventId}
className="flex items-start justify-between gap-4 text-sm"
>
<div className="space-y-1">
<div className="font-mono text-xs text-muted-foreground">
event: {f.scoringEventId}
</div>
<div className="font-mono text-xs text-muted-foreground">
season: {f.sportsSeasonId}
</div>
<div className="text-destructive">{f.error}</div>
</div>
<retryFetcher.Form method="post">
<input
type="hidden"
name="intent"
value="retry-window-sync"
/>
<input
type="hidden"
name="scoringEventId"
value={f.scoringEventId}
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={retryFetcher.state !== "idle"}
>
{retryFetcher.state !== "idle"
? "Retrying…"
: "Retry"}
</Button>
</retryFetcher.Form>
</div>
))}
</div>
</div>
</CardContent>
)}
</Card>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
{actionData.error}
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Current Results</CardTitle>
<CardDescription>
{results.length}{" "}
{results.length === 1 ? "result" : "results"} recorded
</CardDescription>
</CardHeader>
<CardContent>
{results.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
No results recorded yet. Paste a ranked list to the right to
import.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">Placement</TableHead>
<TableHead>Participant</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((r) => {
const p = participantById.get(r.participantId);
return (
<TableRow key={r.id}>
<TableCell className="font-semibold">
{r.placement ?? "—"}
</TableCell>
<TableCell>
{p?.name ?? (
<span className="text-muted-foreground font-mono text-xs">
{r.participantId}
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<BatchResultEntry
participants={canonicalParticipants.map((p) => ({
id: p.id,
name: p.name,
}))}
sportsSeasonId=""
existingResultParticipantIds={
new Set(results.map((r) => r.participantId))
}
intent="batch-upsert-results"
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,138 @@
import { Link } from "react-router";
import type { Route } from "./+types/admin.tournaments._index";
import { findAllTournamentsGroupedBySport } from "~/models/tournament";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Trophy } from "lucide-react";
export function meta(): Route.MetaDescriptors {
return [{ title: "Tournaments - Brackt Admin" }];
}
export async function loader() {
const grouped = await findAllTournamentsGroupedBySport();
return { grouped };
}
export default function AdminTournamentsIndex({
loaderData,
}: Route.ComponentProps) {
const { grouped } = loaderData;
const totalCount = grouped.reduce(
(acc, g) => acc + g.tournaments.length,
0
);
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">Tournaments</h1>
<p className="text-muted-foreground mt-1">
Canonical tournaments across all sports. Enter results once here
and they fan out to every linked scoring window.
</p>
</div>
</div>
{grouped.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Trophy className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">
No tournaments yet
</h3>
<p className="text-muted-foreground mt-2">
Canonical tournaments are created automatically by the backfill
or data-sync flows.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{totalCount} {totalCount === 1 ? "tournament" : "tournaments"}{" "}
across {grouped.length}{" "}
{grouped.length === 1 ? "sport" : "sports"}
</p>
{grouped.map((group) => (
<Card key={group.sport.id}>
<CardHeader>
<CardTitle>{group.sport.name}</CardTitle>
<CardDescription>
{group.tournaments.length}{" "}
{group.tournaments.length === 1
? "tournament"
: "tournaments"}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Year</TableHead>
<TableHead>Starts</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.tournaments.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>{t.year}</TableCell>
<TableCell className="text-muted-foreground">
{t.startsAt
? new Date(t.startsAt).toLocaleDateString()
: "—"}
</TableCell>
<TableCell>
<Badge
variant={
t.status === "completed"
? "default"
: t.status === "in_progress"
? "secondary"
: "outline"
}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/tournaments/${t.id}`}>
Open
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View file

@ -10,6 +10,7 @@ import {
Calendar, Calendar,
FolderKanban, FolderKanban,
RefreshCw, RefreshCw,
Award,
} from "lucide-react"; } from "lucide-react";
export function meta(): Route.MetaDescriptors { export function meta(): Route.MetaDescriptors {
@ -59,6 +60,12 @@ export default function AdminLayout() {
Sports Seasons Sports Seasons
</Link> </Link>
</Button> </Button>
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to="/admin/tournaments">
<Award className="mr-2 h-4 w-4" />
Tournaments
</Link>
</Button>
<Button variant="ghost" className="w-full justify-start" asChild> <Button variant="ghost" className="w-full justify-start" asChild>
<Link to="/admin/templates"> <Link to="/admin/templates">
<FolderKanban className="mr-2 h-4 w-4" /> <FolderKanban className="mr-2 h-4 w-4" />

View file

@ -0,0 +1,577 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as DrizzleOrm from "drizzle-orm";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
vi.mock("~/models/scoring-calculator", () => ({
processQualifyingEvent: vi.fn(),
}));
import { syncTournamentResults } from "../sync-tournament-results";
import { database } from "~/database/context";
import { processQualifyingEvent } from "~/models/scoring-calculator";
// ---------------------------------------------------------------------------
// Mock data types
// ---------------------------------------------------------------------------
interface FakeTournamentResult {
tournamentId: string;
participantId: string;
placement: number | null;
rawScore: string | null;
}
interface FakeScoringEvent {
id: string;
sportsSeasonId: string;
tournamentId: string | null;
}
interface FakeSeasonParticipant {
id: string;
sportsSeasonId: string;
participantId: string | null;
name: string;
}
interface FakeEventResult {
id: string;
scoringEventId: string;
seasonParticipantId: string;
placement: number | null;
qualifyingPointsAwarded: string | null;
rawScore: string | null;
}
// ---------------------------------------------------------------------------
// Fake in-memory DB
//
// We build a single shared object with a chainable select/insert/update API
// that the service uses identically whether it's the top-level `db` or the
// `tx` inside a transaction. This lets us both observe the writes that would
// have happened and emulate idempotent upserts.
//
// Internal state holds the three tables the service queries:
// - tournament_results
// - scoring_events
// - season_participants
// - event_results
// ---------------------------------------------------------------------------
interface FakeDbState {
tournamentResults: FakeTournamentResult[];
scoringEvents: FakeScoringEvent[];
seasonParticipants: FakeSeasonParticipant[];
eventResults: FakeEventResult[];
}
/**
* A very thin fake drizzle-ish db. The service only uses:
* - db.select().from(table).where(cond) -- returns rows
* - db.insert(table).values(row[|rows]) -- insert
* - db.update(table).set(...).where(cond) -- update
* - db.transaction(fn) -- runs fn with the same db
*
* We identify the target table by reference equality to the schema objects.
* The service passes schema.tournamentResults etc., so we capture the real
* schema module and compare by ref.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function tableName(table: any): string {
// drizzle-orm PgTable exposes the table name under a symbol. Try a few.
const sym = Object.getOwnPropertySymbols(table).find(
(s) => s.toString() === "Symbol(drizzle:Name)"
);
if (sym) return table[sym];
// Fallback for our shim: read `.tableName` property if set.
return table.tableName;
}
function makeFakeDb(state: FakeDbState) {
// We mirror the shape in a lazy way: each predicate function is stored on the
// call so we can match rows. In practice the service only filters by simple
// eq() / and() combinations — we don't try to parse drizzle's SQL tree.
// Instead, we stash the filters the service applies by table.
//
// The simpler and more faithful approach: every call records enough info for
// us to return the right subset.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function rowsFor(table: any): any[] {
// Table objects are drizzle PgTable objects; we identify them by name.
const name = tableName(table);
switch (name) {
case "tournament_results":
return state.tournamentResults;
case "scoring_events":
return state.scoringEvents;
case "season_participants":
return state.seasonParticipants;
case "event_results":
return state.eventResults;
default:
throw new Error(`Unknown table in fake db: ${name}`);
}
}
const db = {
// ---------- select ----------
select: vi.fn().mockImplementation(() => {
const chain = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_table: null as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
from(table: any) {
chain._table = table;
return chain;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where(predicate: any) {
// The predicate is a drizzle SQL expression; we can't introspect it
// easily. The fake predicate stash sets a callback (see below). If
// the predicate is a function we apply it; otherwise we return all
// rows for the table (the caller will then filter).
const rows = rowsFor(chain._table);
if (typeof predicate === "function") {
return Promise.resolve(rows.filter(predicate));
}
// Unknown predicate: the test harness attaches `__fakeFilter` to the
// predicate value we interposed. When we can't evaluate, just return
// all rows — the fake is used only by the service and tests that
// control what rows exist.
return Promise.resolve(rows);
},
};
return chain;
}),
// ---------- insert ----------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
insert: vi.fn().mockImplementation((table: any) => {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values(row: any) {
const rows = Array.isArray(row) ? row : [row];
const existing = rowsFor(table);
for (const r of rows) {
// Assign a fake id for event_results (others aren't re-read by ID).
existing.push({ id: `er-${existing.length + 1}`, ...r });
}
return {
returning: vi.fn().mockResolvedValue(rows),
};
},
};
}),
// ---------- update ----------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
update: vi.fn().mockImplementation((table: any) => {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
set(patch: any) {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where(predicate: any) {
const rows = rowsFor(table);
for (const r of rows) {
if (typeof predicate === "function" ? predicate(r) : true) {
Object.assign(r, patch);
}
}
return Promise.resolve();
},
};
},
};
}),
// ---------- transaction ----------
transaction: vi
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation(async (fn: (tx: any) => any) => fn(db)),
};
return db;
}
// ---------------------------------------------------------------------------
// The service builds drizzle SQL expressions via eq() / and() from
// drizzle-orm. We can't intercept those without re-mocking the whole module.
// Instead we mock drizzle-orm's eq/and to return predicate functions directly.
// ---------------------------------------------------------------------------
vi.mock("drizzle-orm", async () => {
const actual = await vi.importActual<typeof DrizzleOrm>("drizzle-orm");
return {
...actual,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eq: (col: any, val: any) => {
// Each column object (PgColumn) has a `.name` property giving the DB
// column name. Return a predicate that reads that key off the row.
const key = colKey(col);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (row: any) => row[key] === val;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)),
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function colKey(col: any): string {
// drizzle PgColumn: its .name is the snake_case DB name, but our fake state
// uses camelCase JS field names. Map common columns manually.
const n = col?.name ?? col?.config?.name ?? "";
const map: Record<string, string> = {
tournament_id: "tournamentId",
sports_season_id: "sportsSeasonId",
scoring_event_id: "scoringEventId",
season_participant_id: "seasonParticipantId",
participant_id: "participantId",
id: "id",
};
return map[n] ?? n;
}
// ---------------------------------------------------------------------------
// Helpers for building fixture data
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
});
function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
return {
tournamentResults: [],
scoringEvents: [],
seasonParticipants: [],
eventResults: [],
...overrides,
};
}
// ---------------------------------------------------------------------------
// Test 1: Single window, full roster match
// ---------------------------------------------------------------------------
describe("syncTournamentResults", () => {
it("syncs a single window with two canonical results and fills 0-QP for unmatched", async () => {
const state = seedBasicState({
tournamentResults: [
{
tournamentId: "t-1",
participantId: "cp-A",
placement: 1,
rawScore: "280",
},
{
tournamentId: "t-1",
participantId: "cp-B",
placement: 2,
rawScore: "282",
},
],
scoringEvents: [
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
],
seasonParticipants: [
{
id: "sp-A",
sportsSeasonId: "ss-1",
participantId: "cp-A",
name: "A",
},
{
id: "sp-B",
sportsSeasonId: "ss-1",
participantId: "cp-B",
name: "B",
},
{
id: "sp-C",
sportsSeasonId: "ss-1",
participantId: "cp-C",
name: "C",
},
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
const report = await syncTournamentResults("t-1");
expect(report.windowsSynced).toBe(1);
expect(report.windowsFailed).toBe(0);
expect(report.failures).toEqual([]);
const eventRows = state.eventResults.filter(
(r) => r.scoringEventId === "ev-1"
);
// sp-A: placement 1, sp-B: placement 2, sp-C: 0-QP filler
expect(eventRows).toHaveLength(3);
const a = eventRows.find((r) => r.seasonParticipantId === "sp-A");
expect(a?.placement).toBe(1);
expect(a?.rawScore).toBe("280");
const b = eventRows.find((r) => r.seasonParticipantId === "sp-B");
expect(b?.placement).toBe(2);
expect(b?.rawScore).toBe("282");
const c = eventRows.find((r) => r.seasonParticipantId === "sp-C");
expect(c?.placement).toBeNull();
expect(c?.qualifyingPointsAwarded).toBe("0");
expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
});
// -------------------------------------------------------------------------
// Test 2: Two overlapping windows — same tournament, different sports_seasons
// -------------------------------------------------------------------------
it("fans out to multiple windows, each getting only its own participants", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-X", placement: 1, rawScore: null },
{ tournamentId: "t-1", participantId: "cp-Y", placement: 2, rawScore: null },
{ tournamentId: "t-1", participantId: "cp-Z", placement: 3, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
// Window A has X, Y
{ id: "sp-AX", sportsSeasonId: "ss-A", participantId: "cp-X", name: "X" },
{ id: "sp-AY", sportsSeasonId: "ss-A", participantId: "cp-Y", name: "Y" },
// Window B has Y, Z
{ id: "sp-BY", sportsSeasonId: "ss-B", participantId: "cp-Y", name: "Y" },
{ id: "sp-BZ", sportsSeasonId: "ss-B", participantId: "cp-Z", name: "Z" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
const report = await syncTournamentResults("t-1");
expect(report.windowsSynced).toBe(2);
expect(report.windowsFailed).toBe(0);
const aRows = state.eventResults.filter((r) => r.scoringEventId === "ev-A");
const bRows = state.eventResults.filter((r) => r.scoringEventId === "ev-B");
// Window A: sp-AX (placement 1), sp-AY (placement 2). No 0-QP fillers
// since both roster members got results.
expect(aRows).toHaveLength(2);
expect(aRows.find((r) => r.seasonParticipantId === "sp-AX")?.placement).toBe(1);
expect(aRows.find((r) => r.seasonParticipantId === "sp-AY")?.placement).toBe(2);
// Window B: sp-BY (placement 2), sp-BZ (placement 3). Again no fillers.
expect(bRows).toHaveLength(2);
expect(bRows.find((r) => r.seasonParticipantId === "sp-BY")?.placement).toBe(2);
expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
});
// -------------------------------------------------------------------------
// Test 3: Idempotent re-run
// -------------------------------------------------------------------------
it("is idempotent: a second run leaves the same end state", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: "10" },
],
scoringEvents: [
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
{ id: "sp-B", sportsSeasonId: "ss-1", participantId: "cp-B", name: "B" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
const afterFirst = JSON.parse(JSON.stringify(state.eventResults));
await syncTournamentResults("t-1");
const afterSecond = state.eventResults;
// Same row count, same per-row placement / QP / participant / event.
expect(afterSecond).toHaveLength(afterFirst.length);
for (const first of afterFirst) {
const match = afterSecond.find(
(r) =>
r.scoringEventId === first.scoringEventId &&
r.seasonParticipantId === first.seasonParticipantId
);
expect(match).toBeDefined();
expect(match?.placement).toBe(first.placement);
expect(match?.qualifyingPointsAwarded).toBe(first.qualifyingPointsAwarded);
expect(match?.rawScore).toBe(first.rawScore);
}
});
// -------------------------------------------------------------------------
// Test 4: Correction propagation
// -------------------------------------------------------------------------
it("propagates canonical placement corrections to all linked windows", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
// Sanity — both windows have sp-* at placement 1.
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.placement
).toBe(1);
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.placement
).toBe(1);
// Correct the canonical placement from 1 to 5.
state.tournamentResults[0].placement = 5;
state.tournamentResults[0].rawScore = "99";
await syncTournamentResults("t-1");
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.placement
).toBe(5);
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.rawScore
).toBe("99");
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.placement
).toBe(5);
expect(
state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.rawScore
).toBe("99");
});
// -------------------------------------------------------------------------
// Test 5: Partial failure isolation
// -------------------------------------------------------------------------
it("isolates failures: one window's error does not stop the others", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
{ id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
{ id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
// First window throws in processQualifyingEvent; second succeeds.
vi.mocked(processQualifyingEvent)
.mockImplementationOnce(async () => {
throw new Error("QP calc failed");
})
.mockImplementationOnce(async () => undefined);
const report = await syncTournamentResults("t-1");
expect(report.windowsSynced).toBe(1);
expect(report.windowsFailed).toBe(1);
expect(report.failures).toHaveLength(1);
expect(report.failures[0].scoringEventId).toBe("ev-A");
expect(report.failures[0].sportsSeasonId).toBe("ss-A");
expect(report.failures[0].error).toContain("QP calc failed");
// Second window (ev-B) still has its row committed in our fake (we don't
// simulate rollback, but the service called through to the writes path,
// which is what we care about — the try/catch did not propagate).
expect(
state.eventResults.find(
(r) => r.scoringEventId === "ev-B" && r.seasonParticipantId === "sp-BA"
)
).toBeDefined();
expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
});
// -------------------------------------------------------------------------
// Test 6: Participant in roster but not in canonical results → 0-QP filler
// -------------------------------------------------------------------------
it("writes 0-QP filler rows for roster participants absent from canonical results", async () => {
const state = seedBasicState({
tournamentResults: [
{ tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
],
scoringEvents: [
{ id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
],
seasonParticipants: [
{ id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
{ id: "sp-B", sportsSeasonId: "ss-1", participantId: "cp-B", name: "B" },
{ id: "sp-C", sportsSeasonId: "ss-1", participantId: "cp-C", name: "C" },
],
});
const db = makeFakeDb(state);
vi.mocked(database).mockReturnValue(db as never);
vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);
await syncTournamentResults("t-1");
const rows = state.eventResults;
expect(rows).toHaveLength(3);
const a = rows.find((r) => r.seasonParticipantId === "sp-A");
expect(a?.placement).toBe(1);
expect(a?.qualifyingPointsAwarded).toBeUndefined(); // not set by this service
const b = rows.find((r) => r.seasonParticipantId === "sp-B");
expect(b?.placement).toBeNull();
expect(b?.qualifyingPointsAwarded).toBe("0");
const c = rows.find((r) => r.seasonParticipantId === "sp-C");
expect(c?.placement).toBeNull();
expect(c?.qualifyingPointsAwarded).toBe("0");
});
});

View file

@ -0,0 +1,138 @@
import { eq, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { processQualifyingEvent } from "~/models/scoring-calculator";
export interface SyncReport {
tournamentId: string;
windowsSynced: number;
windowsFailed: number;
failures: Array<{
scoringEventId: string;
sportsSeasonId: string;
error: string;
}>;
}
/**
* Fan out canonical tournament_results to every scoring_event window that
* points at this tournament. For each linked window:
* 1. Upsert event_results rows mirroring canonical placement / rawScore for
* every roster participant that appears in the canonical results.
* 2. Write 0-QP filler rows for roster participants who have no result.
* 3. Delegate to processQualifyingEvent to compute QP.
*
* Each window runs in its own transaction so partial failures isolate.
* Errors in one window never interrupt sync of the remaining windows.
*
* This service NEVER writes qualifying_points_awarded directly (except the
* "0" filler for participants with no placement) QP calculation for real
* results is owned by processQualifyingEvent.
*/
export async function syncTournamentResults(
tournamentId: string
): Promise<SyncReport> {
const db = database();
const report: SyncReport = {
tournamentId,
windowsSynced: 0,
windowsFailed: 0,
failures: [],
};
// 1. Load canonical results for this tournament.
const canonicalResults = await db
.select()
.from(schema.tournamentResults)
.where(eq(schema.tournamentResults.tournamentId, tournamentId));
// 2. Load every scoring_event that points at this tournament.
const linkedEvents = await db
.select()
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
// 3. Fan out — each event gets its own transaction.
for (const ev of linkedEvents) {
try {
await db.transaction(async (tx: typeof db) => {
// 3a. Window roster = season_participants for this event's sports_season.
const rosters = await tx
.select()
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, ev.sportsSeasonId));
// 3b. For each canonical result whose participant is on the roster,
// upsert the matching event_results row. Only placement / rawScore
// are copied; qualifying_points_awarded is left for
// processQualifyingEvent to recompute.
for (const cr of canonicalResults) {
const sp = rosters.find((r) => r.participantId === cr.participantId);
if (!sp) continue;
const [existing] = await tx
.select()
.from(schema.eventResults)
.where(
and(
eq(schema.eventResults.scoringEventId, ev.id),
eq(schema.eventResults.seasonParticipantId, sp.id)
)
);
if (existing) {
await tx
.update(schema.eventResults)
.set({
placement: cr.placement,
rawScore: cr.rawScore,
updatedAt: new Date(),
})
.where(eq(schema.eventResults.id, existing.id));
} else {
await tx.insert(schema.eventResults).values({
scoringEventId: ev.id,
seasonParticipantId: sp.id,
placement: cr.placement,
rawScore: cr.rawScore,
});
}
}
// 3c. 0-QP filler pass: every roster member without an event_results
// row for this event gets one with placement=null, QP=0. This matches
// the behavior from the per-window batch-qualifying-results design.
const afterRows = await tx
.select()
.from(schema.eventResults)
.where(eq(schema.eventResults.scoringEventId, ev.id));
const covered = new Set(afterRows.map((r) => r.seasonParticipantId));
for (const sp of rosters) {
if (covered.has(sp.id)) continue;
await tx.insert(schema.eventResults).values({
scoringEventId: ev.id,
seasonParticipantId: sp.id,
placement: null,
qualifyingPointsAwarded: "0",
});
}
// 3d. Delegate to scoring engine inside the same transaction.
await processQualifyingEvent(ev.id, tx);
});
report.windowsSynced += 1;
} catch (e: unknown) {
report.windowsFailed += 1;
const msg =
e instanceof Error ? e.message : typeof e === "string" ? e : String(e);
report.failures.push({
scoringEventId: ev.id,
sportsSeasonId: ev.sportsSeasonId,
error: msg,
});
}
}
return report;
}

View file

@ -1,4 +1,4 @@
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core"; import { check, integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
export const users = pgTable("users", { export const users = pgTable("users", {
@ -554,7 +554,12 @@ export const scoringEvents = pgTable("scoring_events", {
completedAt: timestamp("completed_at"), completedAt: timestamp("completed_at"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); }, (t) => ({
qualifyingEventsRequireTournament: check(
"scoring_events_qualifying_require_tournament",
sql`NOT ${t.isQualifyingEvent} OR ${t.tournamentId} IS NOT NULL`,
),
}));
// Results for participants in specific events // Results for participants in specific events
export const eventResults = pgTable("event_results", { export const eventResults = pgTable("event_results", {
@ -575,7 +580,12 @@ export const eventResults = pgTable("event_results", {
rawScore: decimal("raw_score", { precision: 10, scale: 2 }), // strokes, points, time, etc. rawScore: decimal("raw_score", { precision: 10, scale: 2 }), // strokes, points, time, etc.
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); }, (t) => ({
uniqueEventParticipant: uniqueIndex("event_results_event_participant_unique").on(
t.scoringEventId,
t.seasonParticipantId,
),
}));
// Playoff bracket matches (for display and tracking) // Playoff bracket matches (for display and tracking)
export const playoffMatches = pgTable("playoff_matches", { export const playoffMatches = pgTable("playoff_matches", {
@ -1367,38 +1377,9 @@ export const pendingStandingsMappingsRelations = relations(pendingStandingsMappi
}), }),
})); }));
// ─── Participant Surface Elos ───────────────────────────────────────────────── // Surface Elos are now stored canonically in `participant_surface_elos`
// Stores surface-specific Elo ratings for tennis (and future surface-based sports). // (defined above). The per-window `season_participant_surface_elos` table
// One row per (participantId, sportsSeasonId); nullable per surface. // was dropped in Phase 4 of the canonical tournament layer migration.
export const seasonParticipantSurfaceElos = pgTable("season_participant_surface_elos", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => seasonParticipants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
worldRanking: integer("world_ranking"),
eloHard: integer("elo_hard"),
eloClay: integer("elo_clay"),
eloGrass: integer("elo_grass"),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => ({
uniqueParticipantSeason: uniqueIndex("participant_surface_elos_unique")
.on(t.participantId, t.sportsSeasonId),
}));
export const seasonParticipantSurfaceElosRelations = relations(seasonParticipantSurfaceElos, ({ one }) => ({
participant: one(seasonParticipants, {
fields: [seasonParticipantSurfaceElos.participantId],
references: [seasonParticipants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [seasonParticipantSurfaceElos.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));
// ─── Participant Golf Skills ─────────────────────────────────────────────────── // ─── Participant Golf Skills ───────────────────────────────────────────────────
// Stores golf-specific skill data for qualifying-points golf seasons. // Stores golf-specific skill data for qualifying-points golf seasons.

View file

@ -13,10 +13,33 @@
**NEVER** manually create migration SQL files or edit `drizzle/meta/_journal.json`. The journal and snapshots must stay in sync — drizzle-kit manages both. Missing snapshots cause `drizzle-kit generate` to fail with "data is malformed". **NEVER** manually create migration SQL files or edit `drizzle/meta/_journal.json`. The journal and snapshots must stay in sync — drizzle-kit manages both. Missing snapshots cause `drizzle-kit generate` to fail with "data is malformed".
- Always use `drizzle-kit generate` (or `drizzle-kit generate --custom` for interactive rename prompts) - Always use `drizzle-kit generate` and answer any interactive rename prompts (`"Is X renamed from Y?"`) — do NOT use `--custom` for renames, it copies the previous snapshot instead of diffing, which causes the next run to generate a duplicate migration
- Snapshot JSON must only contain PostgreSQL-valid fields — do not add `"autoincrement"` (Postgres uses sequences, and invalid fields cause Zod validation failures) - Snapshot JSON must only contain PostgreSQL-valid fields — do not add `"autoincrement"` (Postgres uses sequences, and invalid fields cause Zod validation failures)
- After generating, run `drizzle-kit generate` again to confirm "No schema changes" — validates the snapshot chain - After generating, run `drizzle-kit generate` again to confirm "No schema changes" — validates the snapshot chain
- For destructive migrations (drop/rename), use `IF EXISTS` / `IF NOT EXISTS` guards - For destructive migrations (drop/rename), use `IF EXISTS` / `IF NOT EXISTS` guards
- Some existing FK constraints use Postgres auto-generated `*_fkey` names instead of the Drizzle `*_fk` convention. If drizzle-kit generates `DROP CONSTRAINT "<table>_<col>_<ref>_id_fk"` and it fails with "does not exist", add `IF EXISTS` (via `sed -i` on the SQL file) — a mismatched name is the likely cause
## Canonical vs. Per-Window Tables (Qualifying-Points Sports)
Golf, tennis, and CS2 use a split model to support rolling windows that overlap (e.g., a June and September tennis window both containing Wimbledon 2026 + US Open 2026).
- **Canonical** (real-world identity, shared across windows):
- `tournaments` — real-world tournaments (Wimbledon 2026 is one row per sport)
- `participants` — real-world players or teams (Djokovic, NAVI)
- `tournament_results` — final placements, entered once
- `participant_surface_elos` — tennis surface-specific Elo
- **Per-window** (scoped to a `sports_seasons` row):
- `season_participants` — draftable roster for this window; links to canonical via `participant_id`
- `season_participant_expected_values` — EV under this window's 4-event set
- `season_participant_qualifying_totals` — running QP sum for this window
- `season_participant_results` — final placement for fantasy scoring
- `event_results` — per-scoring-event placements (derived from `tournament_results` via `syncTournamentResults`)
Enter tournament results at `/admin/tournaments/:id`. The `syncTournamentResults` service (`app/services/sync-tournament-results.ts`) fans out to every window linked via `scoring_events.tournament_id` — each window in its own transaction, so partial failures isolate. A CHECK constraint on `scoring_events` enforces `NOT is_qualifying_event OR tournament_id IS NOT NULL`.
When admin creates a new qualifying event via the events admin page, `createScoringEvent` auto-provisions the matching canonical tournament. When admin creates a new season_participant, `createParticipant` auto-links to a canonical participant by `(sport_id, name)`.
Tennis note: Brackt models tennis as two sports (`Tennis - Men`, `Tennis - Women`). Each real-world tournament becomes two canonical rows. Entering women's Wimbledon does not fan out to men's windows — by design.
## Scoring Event Dates (Non-Obvious Gotcha) ## Scoring Event Dates (Non-Obvious Gotcha)

View file

@ -27,8 +27,19 @@ Season status drives UI visibility: draft order shown in `pre_draft`, draft cont
| Model | Description | | Model | Description |
|---|---| |---|---|
| **Sport** | Base sport definition (e.g. NFL, NBA); type is `team` or `individual` | | **Sport** | Base sport definition (e.g. NFL, NBA); type is `team` or `individual` |
| **Sports Season** | A specific season of a sport (e.g. "2024 NFL Season") | | **Sports Season** | A specific rolling window of a sport (e.g. "Tennis June 2026") |
| **Participant** | An athlete or team that can be drafted | | **Season Participant** | A draftable roster entry for one sports season — links to a canonical Participant |
| **Season Template** | Reusable configuration for creating league seasons | | **Season Template** | Reusable configuration for creating league seasons |
| **Season Sport** | Junction: links a fantasy season to the sports it covers | | **Season Sport** | Junction: links a fantasy season to the sports it covers |
| **Participant Result** | Final standings/scores for participants at season end | | **Season Participant Result** | Final standings/scores for season participants |
## Canonical Layer (Qualifying-Points Sports)
Golf, tennis, and CS2 rolling windows share real-world data via a canonical layer. See `docs/agents/database.md` for the schema split.
| Model | Description |
|---|---|
| **Participant (canonical)** | Real-world player or team (Djokovic, NAVI) — shared across windows |
| **Tournament** | Real-world tournament (Wimbledon 2026) — shared across windows |
| **Tournament Result** | Final placement in a real-world tournament — entered once at `/admin/tournaments/:id`, fanned out to every linked window via `syncTournamentResults` |
| **Participant Surface Elo** | Canonical surface-specific Elo (tennis) — read by the tennis simulator |

View file

@ -0,0 +1,2 @@
CREATE UNIQUE INDEX IF NOT EXISTS "event_results_event_participant_unique" ON "event_results" USING btree ("scoring_event_id","season_participant_id");--> statement-breakpoint
ALTER TABLE "scoring_events" ADD CONSTRAINT "scoring_events_qualifying_require_tournament" CHECK (NOT "scoring_events"."is_qualifying_event" OR "scoring_events"."tournament_id" IS NOT NULL);

View file

@ -0,0 +1 @@
DROP TABLE "season_participant_surface_elos" CASCADE;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -624,6 +624,20 @@
"when": 1777666830394, "when": 1777666830394,
"tag": "0088_cheerful_norrin_radd", "tag": "0088_cheerful_norrin_radd",
"breakpoints": true "breakpoints": true
},
{
"idx": 89,
"version": "7",
"when": 1777675084016,
"tag": "0089_lonely_tigra",
"breakpoints": true
},
{
"idx": 90,
"version": "7",
"when": 1777677410094,
"tag": "0090_powerful_redwing",
"breakpoints": true
} }
] ]
} }

View file

@ -9,7 +9,6 @@
"db:generate": "dotenv -- drizzle-kit generate", "db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate", "db:migrate": "dotenv -- drizzle-kit migrate",
"db:sync-prod": "bash scripts/sync-prod-db.sh", "db:sync-prod": "bash scripts/sync-prod-db.sh",
"backfill:canonical": "dotenv -- tsx scripts/backfill-cli.ts",
"dev": "NODE_OPTIONS='--import ./instrument.server.mjs' dotenv -- tsx watch server.ts", "dev": "NODE_OPTIONS='--import ./instrument.server.mjs' dotenv -- tsx watch server.ts",
"start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js", "start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js",
"start:production": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js", "start:production": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js",

View file

@ -1,450 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
import { runBackfill } from "../backfill-canonical-layer";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
/**
* Fixture rows used across multiple tests.
*/
const SPORT_ID = "sport-golf";
const SEASON_ID = "season-golf-2026";
const GOLF_SEASON = {
id: SEASON_ID,
sportId: SPORT_ID,
scoringPattern: "qualifying_points",
} as const;
function makeEvent(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "event-1",
sportsSeasonId: SEASON_ID,
tournamentId: null,
name: "Masters Tournament",
eventDate: "2026-04-09",
eventType: "tournament",
...overrides,
};
}
function makeSeasonParticipant(
overrides: Partial<Record<string, unknown>> = {},
) {
return {
id: "sp-1",
sportsSeasonId: SEASON_ID,
participantId: null,
name: "Scottie Scheffler",
...overrides,
};
}
/**
* Builds a fake drizzle db that routes select/insert/update calls based
* on the target table. The caller supplies per-table select-result
* arrays (one per call to that table's select()). Inserts return the
* value they were given, extended with a stub id. Updates are recorded
* but otherwise no-op.
*/
interface TableState {
/** Queue of results to return for successive select() calls on this table. */
selects?: unknown[][];
/** Rows that insert().values().returning() should yield. */
insertReturns?: unknown[];
/** Incremented on each update() call. */
updates?: { count: number };
}
interface FakeDbOptions {
tables: Map<unknown, TableState>;
/** Records every insert call: tableRef → rows seen. */
inserts?: Map<unknown, unknown[]>;
/** Records every update call: tableRef → count. */
updateCounts?: Map<unknown, number>;
}
function makeFakeDb(opts: FakeDbOptions) {
const { tables, inserts, updateCounts } = opts;
// Each select() starts a new chain that ultimately resolves to the
// next queued selects[] entry for the passed table. The chain is a
// thenable via .limit/.where resolution.
const db = {
select: vi.fn().mockImplementation(() => {
let boundTable: unknown;
const chain: Record<string, unknown> = {
from: vi.fn().mockImplementation((table: unknown) => {
boundTable = table;
return chain;
}),
where: vi.fn().mockImplementation(() => chain),
limit: vi.fn().mockImplementation(() => chain),
// Terminal: make chain thenable so `await chain` yields the queued result.
then: (resolve: (v: unknown) => unknown) => {
const state = tables.get(boundTable);
const queue = state?.selects ?? [];
const next = queue.shift() ?? [];
return Promise.resolve(next).then(resolve);
},
};
return chain;
}),
insert: vi.fn().mockImplementation((table: unknown) => {
return {
values: vi.fn().mockImplementation((row: unknown) => {
if (inserts) {
const seen = inserts.get(table) ?? [];
seen.push(row);
inserts.set(table, seen);
}
const state = tables.get(table);
const returnRows = state?.insertReturns ?? [
{ ...(row as object), id: `generated-${Math.random()}` },
];
const afterValues = {
returning: vi.fn().mockResolvedValue(returnRows),
// If the caller doesn't chain .returning(), make it awaitable anyway.
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve(returnRows).then(resolve),
};
return afterValues;
}),
};
}),
update: vi.fn().mockImplementation((table: unknown) => {
if (updateCounts) {
updateCounts.set(table, (updateCounts.get(table) ?? 0) + 1);
}
return {
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
};
}),
};
return db;
}
beforeEach(() => {
vi.clearAllMocks();
});
// ─── 1. empty DB no-op ─────────────────────────────────────────────────────
describe("runBackfill - empty DB", () => {
it("returns zero counts when no qualifying-points seasons exist", async () => {
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[]] }],
]);
vi.mocked(database).mockReturnValue(makeFakeDb({ tables }) as never);
const report = await runBackfill({ dryRun: false });
expect(report).toMatchObject({
tournamentsCreated: 0,
tournamentsLinked: 0,
participantsCreated: 0,
participantsLinked: 0,
tournamentResultsCreated: 0,
surfaceElosCreated: 0,
errors: [],
});
});
});
// ─── 2. golf window with 4 events creates 4 tournaments ───────────────────
describe("runBackfill - golf tournaments", () => {
it("creates 4 canonical tournaments for a golf season with 4 events", async () => {
const events = [
makeEvent({ id: "ev-1", name: "Masters Tournament", eventDate: "2026-04-09" }),
makeEvent({ id: "ev-2", name: "PGA Championship", eventDate: "2026-05-14" }),
makeEvent({ id: "ev-3", name: "U.S. Open", eventDate: "2026-06-18" }),
makeEvent({ id: "ev-4", name: "The Open Championship", eventDate: "2026-07-16" }),
];
const inserts = new Map<unknown, unknown[]>();
// Build per-event insertReturns so each new tournament gets its id.
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[
schema.scoringEvents,
{
// 1st select: unlinked events; 2nd: refetch for results loop.
selects: [events, events.map((e, i) => ({ ...e, tournamentId: `t-${i + 1}` }))],
},
],
[
schema.tournaments,
{
// One select-miss per event, all return [] (no existing row).
selects: [[], [], [], []],
// One insert per event; return sequential ids.
insertReturns: [{ id: "t-1" }],
},
],
[schema.seasonParticipants, { selects: [[]] }],
[schema.eventResults, { selects: [[], [], [], []] }],
[schema.seasonParticipantSurfaceElos, { selects: [[]] }],
]);
// Because our insertReturns is consumed once per test, re-queue per event
// by overriding insert behaviour via the fake db options.
const tournamentIds = ["t-1", "t-2", "t-3", "t-4"];
let insertIdx = 0;
const db: ReturnType<typeof makeFakeDb> = makeFakeDb({ tables, inserts });
// Override insert for tournaments specifically to return sequential ids.
db.insert.mockImplementation((table: unknown) => ({
values: vi.fn().mockImplementation((row: unknown) => {
const seen = inserts.get(table) ?? [];
seen.push(row);
inserts.set(table, seen);
let returnRows: unknown[];
if (table === schema.tournaments) {
returnRows = [{ ...(row as object), id: tournamentIds[insertIdx++] }];
} else {
returnRows = [{ ...(row as object), id: "x" }];
}
return {
returning: vi.fn().mockResolvedValue(returnRows),
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve(returnRows).then(resolve),
};
}),
}));
vi.mocked(database).mockReturnValue(db as never);
const report = await runBackfill({ dryRun: false });
expect(report.tournamentsCreated).toBe(4);
expect(report.tournamentsLinked).toBe(4);
expect(inserts.get(schema.tournaments)).toHaveLength(4);
expect(report.errors).toEqual([]);
});
});
// ─── 3. re-running doesn't duplicate ──────────────────────────────────────
describe("runBackfill - idempotence", () => {
it("does not create new tournaments when they already exist", async () => {
const events = [
makeEvent({ id: "ev-1", name: "Masters Tournament", eventDate: "2026-04-09" }),
];
const existingTournament = {
id: "t-existing",
sportId: SPORT_ID,
name: "Masters Tournament",
year: 2026,
};
const inserts = new Map<unknown, unknown[]>();
const updateCounts = new Map<unknown, number>();
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[
schema.scoringEvents,
{
selects: [events, [{ ...events[0], tournamentId: "t-existing" }]],
},
],
[schema.tournaments, { selects: [[existingTournament]] }],
[schema.seasonParticipants, { selects: [[]] }],
[schema.eventResults, { selects: [[]] }],
[schema.seasonParticipantSurfaceElos, { selects: [[]] }],
]);
vi.mocked(database).mockReturnValue(
makeFakeDb({ tables, inserts, updateCounts }) as never,
);
const report = await runBackfill({ dryRun: false });
expect(report.tournamentsCreated).toBe(0);
expect(report.tournamentsLinked).toBe(1);
expect(inserts.get(schema.tournaments)).toBeUndefined();
});
});
// ─── 4. participant backfill ───────────────────────────────────────────────
describe("runBackfill - participants", () => {
it("creates canonical participants and links season_participants", async () => {
const sps = [
makeSeasonParticipant({ id: "sp-1", name: "Scottie Scheffler" }),
makeSeasonParticipant({ id: "sp-2", name: "Rory McIlroy" }),
];
const inserts = new Map<unknown, unknown[]>();
const updateCounts = new Map<unknown, number>();
const pIds = ["p-1", "p-2"];
let pIdx = 0;
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[schema.scoringEvents, { selects: [[], []] }],
[schema.seasonParticipants, { selects: [sps] }],
[schema.participants, { selects: [[], []] }],
[schema.eventResults, { selects: [] }],
[schema.seasonParticipantSurfaceElos, { selects: [[]] }],
]);
const db = makeFakeDb({ tables, inserts, updateCounts });
db.insert.mockImplementation((table: unknown) => ({
values: vi.fn().mockImplementation((row: unknown) => {
const seen = inserts.get(table) ?? [];
seen.push(row);
inserts.set(table, seen);
let returnRows: unknown[];
if (table === schema.participants) {
returnRows = [{ ...(row as object), id: pIds[pIdx++] }];
} else {
returnRows = [{ ...(row as object), id: "x" }];
}
return {
returning: vi.fn().mockResolvedValue(returnRows),
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve(returnRows).then(resolve),
};
}),
}));
vi.mocked(database).mockReturnValue(db as never);
const report = await runBackfill({ dryRun: false });
expect(report.participantsCreated).toBe(2);
expect(report.participantsLinked).toBe(2);
expect(inserts.get(schema.participants)).toHaveLength(2);
expect(updateCounts.get(schema.seasonParticipants)).toBe(2);
});
});
// ─── 5. Masters 2026 round-trip ───────────────────────────────────────────
describe("runBackfill - tournament_results from event_results", () => {
it("copies placement and rawScore but NOT qualifying_points_awarded", async () => {
const events = [
makeEvent({
id: "ev-masters",
tournamentId: "t-masters",
name: "Masters Tournament",
eventDate: "2026-04-09",
}),
];
const seasonParticipant = {
id: "sp-1",
sportsSeasonId: SEASON_ID,
participantId: "p-scottie",
name: "Scottie Scheffler",
};
const eventResult = {
id: "er-1",
scoringEventId: "ev-masters",
seasonParticipantId: "sp-1",
placement: 1,
rawScore: "-12.00",
qualifyingPointsAwarded: "100.00",
};
const inserts = new Map<unknown, unknown[]>();
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[
schema.scoringEvents,
{
// 1st: no unlinked events (tournamentId already set)
// 2nd: refetch — events linked to tournament
selects: [[], events],
},
],
[schema.tournaments, { selects: [] }],
[schema.seasonParticipants, { selects: [[], [seasonParticipant]] }],
[schema.participants, { selects: [] }],
[schema.eventResults, { selects: [[eventResult]] }],
[schema.tournamentResults, { selects: [[]] }],
[schema.seasonParticipantSurfaceElos, { selects: [[]] }],
]);
vi.mocked(database).mockReturnValue(
makeFakeDb({ tables, inserts }) as never,
);
const report = await runBackfill({ dryRun: false });
expect(report.tournamentResultsCreated).toBe(1);
const trInserts = inserts.get(schema.tournamentResults) ?? [];
expect(trInserts).toHaveLength(1);
const inserted = trInserts[0] as Record<string, unknown>;
expect(inserted.tournamentId).toBe("t-masters");
expect(inserted.participantId).toBe("p-scottie");
expect(inserted.placement).toBe(1);
expect(inserted.rawScore).toBe("-12.00");
// CRITICAL: qualifying_points_awarded must NOT be copied.
expect(inserted).not.toHaveProperty("qualifyingPointsAwarded");
});
});
// ─── 6. Surface elo conflict detection ────────────────────────────────────
describe("runBackfill - surface elo conflicts", () => {
it("records a conflict error when two windows disagree on eloHard", async () => {
const sp = {
id: "sp-1",
sportsSeasonId: SEASON_ID,
participantId: "p-tennis-1",
name: "Carlos Alcaraz",
};
const windowElo = {
id: "se-1",
participantId: "sp-1",
sportsSeasonId: SEASON_ID,
eloHard: 2050,
eloClay: 2100,
eloGrass: 2000,
worldRanking: 2,
};
const existingCanonicalElo = {
id: "cpe-1",
participantId: "p-tennis-1",
eloHard: 2000, // differs!
eloClay: 2100,
eloGrass: 2000,
worldRanking: 2,
};
const inserts = new Map<unknown, unknown[]>();
const tables = new Map<unknown, TableState>([
[schema.sportsSeasons, { selects: [[GOLF_SEASON]] }],
[schema.scoringEvents, { selects: [[], []] }],
[schema.seasonParticipants, { selects: [[], [sp]] }],
[schema.participants, { selects: [] }],
[schema.eventResults, { selects: [] }],
[schema.seasonParticipantSurfaceElos, { selects: [[windowElo]] }],
[schema.participantSurfaceElos, { selects: [[existingCanonicalElo]] }],
]);
vi.mocked(database).mockReturnValue(
makeFakeDb({ tables, inserts }) as never,
);
const report = await runBackfill({ dryRun: false });
expect(report.surfaceElosCreated).toBe(0);
expect(report.errors).toHaveLength(1);
expect(report.errors[0]).toMatch(/conflict for participant p-tennis-1/);
expect(report.errors[0]).toMatch(/eloHard/);
// Must not have inserted a conflicting row.
expect(inserts.get(schema.participantSurfaceElos)).toBeUndefined();
});
});

View file

@ -1,358 +0,0 @@
/**
* Phase 2 one-off backfill: populate canonical tables
* (`tournaments`, `participants`, `tournament_results`,
* `participant_surface_elos`) from existing per-window data.
*
* See CLAUDE.md and the Phase 2 plan docs. Rules that this script
* MUST obey:
* - Never copy `qualifying_points_awarded` from `event_results` to
* `tournament_results`. QP stays per-window.
* - Never touch `season_participant_qualifying_totals`.
* - Abort loud (collect into `report.errors`) if two windows disagree
* on a canonical participant's surface-Elo values.
*
* `dryRun: true` means: run every read, compute every count, but never
* issue an `insert()` or `update()`.
*/
import { eq, and, isNull } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { extractTournamentIdentity } from "./backfill/match-tournament";
export interface BackfillOptions {
dryRun: boolean;
sportId?: string;
}
export interface BackfillReport {
tournamentsCreated: number;
/** Count of scoring_events whose tournament_id was (or would be) set. */
tournamentsLinked: number;
participantsCreated: number;
/** Count of season_participants whose participant_id was (or would be) set. */
participantsLinked: number;
tournamentResultsCreated: number;
surfaceElosCreated: number;
warnings: string[];
errors: string[];
}
type Db = ReturnType<typeof database>;
type SportsSeasonRow = typeof schema.sportsSeasons.$inferSelect;
type ScoringEventRow = typeof schema.scoringEvents.$inferSelect;
type SeasonParticipantRow = typeof schema.seasonParticipants.$inferSelect;
type EventResultRow = typeof schema.eventResults.$inferSelect;
type SeasonParticipantSurfaceEloRow =
typeof schema.seasonParticipantSurfaceElos.$inferSelect;
type TournamentRow = typeof schema.tournaments.$inferSelect;
type ParticipantRow = typeof schema.participants.$inferSelect;
type TournamentResultRow = typeof schema.tournamentResults.$inferSelect;
type ParticipantSurfaceEloRow =
typeof schema.participantSurfaceElos.$inferSelect;
export async function runBackfill(
opts: BackfillOptions,
): Promise<BackfillReport> {
const report: BackfillReport = {
tournamentsCreated: 0,
tournamentsLinked: 0,
participantsCreated: 0,
participantsLinked: 0,
tournamentResultsCreated: 0,
surfaceElosCreated: 0,
warnings: [],
errors: [],
};
const db = database();
// 1. Load qualifying-points seasons (optionally filtered by sport).
const whereClauses = [
eq(schema.sportsSeasons.scoringPattern, "qualifying_points"),
];
if (opts.sportId) {
whereClauses.push(eq(schema.sportsSeasons.sportId, opts.sportId));
}
const seasons = (await db
.select()
.from(schema.sportsSeasons)
.where(and(...whereClauses))) as SportsSeasonRow[];
for (const season of seasons) {
await backfillSeason(db, season, opts, report);
}
return report;
}
async function backfillSeason(
db: Db,
season: SportsSeasonRow,
opts: BackfillOptions,
report: BackfillReport,
): Promise<void> {
// ─── a. Tournament linking ────────────────────────────────────────────────
const unlinkedEvents = (await db
.select()
.from(schema.scoringEvents)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, season.id),
isNull(schema.scoringEvents.tournamentId),
),
)) as ScoringEventRow[];
for (const ev of unlinkedEvents) {
let identity;
try {
identity = extractTournamentIdentity({
name: ev.name,
eventDate: ev.eventDate,
eventType: ev.eventType,
});
} catch (e) {
report.warnings.push(
`skip scoring_event ${ev.id} (${ev.name}): ${(e as Error).message}`,
);
continue;
}
// Look up existing canonical tournament.
const [existing] = (await db
.select()
.from(schema.tournaments)
.where(
and(
eq(schema.tournaments.sportId, season.sportId),
eq(schema.tournaments.name, identity.name),
eq(schema.tournaments.year, identity.year),
),
)
.limit(1)) as TournamentRow[];
let tournamentId: string | undefined = existing?.id;
if (!existing) {
const startsAt = ev.eventDate ? new Date(ev.eventDate) : null;
const status =
startsAt && startsAt.getTime() < Date.now() ? "completed" : "scheduled";
if (!opts.dryRun) {
const [inserted] = (await db
.insert(schema.tournaments)
.values({
sportId: season.sportId,
name: identity.name,
year: identity.year,
startsAt,
status,
})
.returning()) as TournamentRow[];
tournamentId = inserted.id;
}
report.tournamentsCreated += 1;
}
if (!opts.dryRun && tournamentId) {
await db
.update(schema.scoringEvents)
.set({ tournamentId, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, ev.id));
}
report.tournamentsLinked += 1;
}
// ─── b. Participant linking ───────────────────────────────────────────────
const unlinkedParticipants = (await db
.select()
.from(schema.seasonParticipants)
.where(
and(
eq(schema.seasonParticipants.sportsSeasonId, season.id),
isNull(schema.seasonParticipants.participantId),
),
)) as SeasonParticipantRow[];
for (const sp of unlinkedParticipants) {
const [existing] = (await db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportId, season.sportId),
eq(schema.participants.name, sp.name),
),
)
.limit(1)) as ParticipantRow[];
let participantId: string | undefined = existing?.id;
if (!existing) {
if (!opts.dryRun) {
const [inserted] = (await db
.insert(schema.participants)
.values({
sportId: season.sportId,
name: sp.name,
})
.returning()) as ParticipantRow[];
participantId = inserted.id;
}
report.participantsCreated += 1;
}
if (!opts.dryRun && participantId) {
await db
.update(schema.seasonParticipants)
.set({ participantId, updatedAt: new Date() })
.where(eq(schema.seasonParticipants.id, sp.id));
}
report.participantsLinked += 1;
}
// ─── c. Tournament results (copy completed event_results) ────────────────
// Refetch events — they may now have tournamentId set (if !dryRun).
const allEvents = (await db
.select()
.from(schema.scoringEvents)
.where(
eq(schema.scoringEvents.sportsSeasonId, season.id),
)) as ScoringEventRow[];
for (const ev of allEvents) {
if (!ev.tournamentId) {
// In dry-run we may not have a tournamentId yet; skip result copy.
continue;
}
const results = (await db
.select()
.from(schema.eventResults)
.where(
eq(schema.eventResults.scoringEventId, ev.id),
)) as EventResultRow[];
for (const r of results) {
// Only copy rows with real placement/rawScore data.
if (r.placement == null && r.rawScore == null) {
continue;
}
// Look up the season_participants row to get canonical participantId.
const [sp] = (await db
.select()
.from(schema.seasonParticipants)
.where(
eq(schema.seasonParticipants.id, r.seasonParticipantId),
)
.limit(1)) as SeasonParticipantRow[];
if (!sp || !sp.participantId) {
// Link step should have handled this; skip defensively.
continue;
}
// Check if a tournament_results row already exists.
const [existingResult] = (await db
.select()
.from(schema.tournamentResults)
.where(
and(
eq(schema.tournamentResults.tournamentId, ev.tournamentId),
eq(schema.tournamentResults.participantId, sp.participantId),
),
)
.limit(1)) as TournamentResultRow[];
if (existingResult) {
continue;
}
if (!opts.dryRun) {
// NOTE: intentionally do NOT copy qualifyingPointsAwarded.
await db.insert(schema.tournamentResults).values({
tournamentId: ev.tournamentId,
participantId: sp.participantId,
placement: r.placement,
rawScore: r.rawScore,
});
}
report.tournamentResultsCreated += 1;
}
}
// ─── d. Surface Elo (per-window → canonical) ─────────────────────────────
const elos = (await db
.select()
.from(schema.seasonParticipantSurfaceElos)
.where(
eq(schema.seasonParticipantSurfaceElos.sportsSeasonId, season.id),
)) as SeasonParticipantSurfaceEloRow[];
for (const elo of elos) {
const [sp] = (await db
.select()
.from(schema.seasonParticipants)
.where(
eq(schema.seasonParticipants.id, elo.participantId),
)
.limit(1)) as SeasonParticipantRow[];
if (!sp || !sp.participantId) {
continue;
}
const canonicalParticipantId = sp.participantId;
const [existingElo] = (await db
.select()
.from(schema.participantSurfaceElos)
.where(
eq(
schema.participantSurfaceElos.participantId,
canonicalParticipantId,
),
)
.limit(1)) as ParticipantSurfaceEloRow[];
if (existingElo) {
// Conflict detection: compare eloHard/eloClay/eloGrass/worldRanking.
const fields: Array<keyof ParticipantSurfaceEloRow> = [
"eloHard",
"eloClay",
"eloGrass",
"worldRanking",
];
const mismatches: string[] = [];
for (const f of fields) {
if (existingElo[f] !== elo[f as keyof SeasonParticipantSurfaceEloRow]) {
mismatches.push(
`${f}: existing=${String(existingElo[f])} vs incoming=${String(elo[f as keyof SeasonParticipantSurfaceEloRow])}`,
);
}
}
if (mismatches.length > 0) {
report.errors.push(
`conflict for participant ${canonicalParticipantId} (season ${season.id}): ${mismatches.join(", ")}`,
);
}
// Do not overwrite.
continue;
}
if (!opts.dryRun) {
await db.insert(schema.participantSurfaceElos).values({
participantId: canonicalParticipantId,
eloHard: elo.eloHard,
eloClay: elo.eloClay,
eloGrass: elo.eloGrass,
worldRanking: elo.worldRanking,
});
}
report.surfaceElosCreated += 1;
}
}

View file

@ -1,82 +0,0 @@
/**
* CLI entry point for the Phase 2 canonical backfill.
*
* Usage:
* npm run backfill:canonical -- [--dry-run | --apply] [--sport=<uuid>]
*
* Defaults to --dry-run for safety. No writes will be issued unless
* --apply is passed explicitly.
*/
import { DatabaseContext } from "~/database/context";
import { db } from "../server/db";
import { runBackfill } from "./backfill-canonical-layer";
import type { BackfillOptions } from "./backfill-canonical-layer";
function printHelp(): void {
console.log(
[
"Usage: backfill-cli [options]",
"",
"Options:",
" --dry-run Run without writing (default).",
" --apply Actually write to the database.",
" --sport=<uuid> Only backfill for the given sport id.",
" --help Show this message.",
].join("\n"),
);
}
function parseArgs(): BackfillOptions {
const args = process.argv.slice(2);
const opts: BackfillOptions = { dryRun: true }; // dry-run by default for safety
for (const a of args) {
if (a === "--apply") {
opts.dryRun = false;
} else if (a === "--dry-run") {
opts.dryRun = true;
} else if (a.startsWith("--sport=")) {
opts.sportId = a.slice("--sport=".length);
} else if (a === "--help" || a === "-h") {
printHelp();
process.exit(0);
} else {
console.error(`unknown arg: ${a}`);
process.exit(1);
}
}
return opts;
}
async function main() {
const opts = parseArgs();
console.log(
`Running backfill (dryRun=${opts.dryRun}, sportId=${opts.sportId ?? "all"})`,
);
const report = await DatabaseContext.run(db, () => runBackfill(opts));
console.log("---");
console.log(`tournamentsCreated: ${report.tournamentsCreated}`);
console.log(`tournamentsLinked: ${report.tournamentsLinked}`);
console.log(`participantsCreated: ${report.participantsCreated}`);
console.log(`participantsLinked: ${report.participantsLinked}`);
console.log(`tournamentResultsCreated: ${report.tournamentResultsCreated}`);
console.log(`surfaceElosCreated: ${report.surfaceElosCreated}`);
if (report.warnings.length) {
console.log("\nWARNINGS:");
for (const w of report.warnings) console.log(` ${w}`);
}
if (report.errors.length) {
console.log("\nERRORS:");
for (const e of report.errors) console.log(` ${e}`);
process.exit(2);
}
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -1,44 +0,0 @@
import { describe, it, expect } from "vitest";
import { extractTournamentIdentity } from "../match-tournament";
describe("extractTournamentIdentity", () => {
it("uses eventDate year when the name has no trailing year", () => {
const identity = extractTournamentIdentity({
name: "Masters Tournament",
eventDate: "2026-04-09",
eventType: "tournament",
});
expect(identity).toEqual({ name: "Masters Tournament", year: 2026 });
});
it("derives the year from eventDate for a simple tournament name", () => {
const identity = extractTournamentIdentity({
name: "Wimbledon",
eventDate: "2026-07-01",
eventType: "tournament",
});
expect(identity).toEqual({ name: "Wimbledon", year: 2026 });
});
it("strips a trailing year from the name and uses it as the canonical year", () => {
const identity = extractTournamentIdentity({
name: "Wimbledon 2026",
eventDate: "2026-07-01",
eventType: "tournament",
});
expect(identity).toEqual({ name: "Wimbledon", year: 2026 });
});
it("throws when neither the name nor eventDate supply a year", () => {
expect(() =>
extractTournamentIdentity({
name: "Wimbledon",
eventDate: null,
eventType: "tournament",
}),
).toThrow(/cannot determine year/);
});
});

View file

@ -1,57 +0,0 @@
/**
* Pure function(s) for extracting canonical tournament identity
* (name + year) from a `scoring_events` row.
*
* Used by the Phase 2 backfill to group per-window events into
* canonical `tournaments` rows.
*/
export interface ScoringEventInput {
name: string;
eventDate: string | null;
eventType: string;
}
export interface TournamentIdentity {
/** Tournament name with any trailing 4-digit year stripped. */
name: string;
year: number;
}
const TRAILING_YEAR_RE = / (\d{4})$/;
/**
* Extracts the canonical `(name, year)` identity for a tournament
* from a scoring event.
*
* Resolution order for year:
* 1. A trailing 4-digit year on the event name (e.g. "Wimbledon 2026").
* The year is stripped from the returned name.
* 2. The first 4 characters of `eventDate` (format `YYYY-MM-DD`).
*
* Throws if neither source supplies a year.
*/
export function extractTournamentIdentity(
ev: ScoringEventInput,
): TournamentIdentity {
const trimmedName = ev.name.trim();
const match = trimmedName.match(TRAILING_YEAR_RE);
if (match) {
const yearFromName = Number(match[1]);
const nameWithoutYear = trimmedName.slice(0, match.index).trim();
return { name: nameWithoutYear, year: yearFromName };
}
if (ev.eventDate) {
const yearStr = ev.eventDate.slice(0, 4);
const yearFromDate = Number(yearStr);
if (Number.isFinite(yearFromDate) && yearStr.length === 4) {
return { name: trimmedName, year: yearFromDate };
}
}
throw new Error(
`cannot determine year for scoring event "${ev.name}" (eventDate=${ev.eventDate ?? "null"})`,
);
}