From 48b1d470f1aa16041bc8ab125c44a7cad4ef8988 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 1 May 2026 21:04:48 -0700 Subject: [PATCH] Canonical tournament layer: cutover + cleanup (2/2) (#367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor: rename participant.ts model file to season-participant.ts Co-Authored-By: Claude Opus 4.7 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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= 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Chris Parsons Co-authored-by: Claude Opus 4.7 --- app/components/BatchResultEntry.tsx | 8 +- app/lib/tournament-identity.ts | 50 + app/models/__tests__/surface-elo.test.ts | 33 +- app/models/scoring-event.ts | 6 +- app/models/season-participant.ts | 72 +- app/models/surface-elo.ts | 137 +- app/models/tournament.ts | 46 +- app/routes.ts | 2 + .../__tests__/admin.tournaments.$id.test.tsx | 182 + ...orts-seasons.$id.events.$eventId.server.ts | 57 +- .../admin.sports-seasons.$id.events.server.ts | 52 + app/routes/admin.tournaments.$id.tsx | 353 + app/routes/admin.tournaments._index.tsx | 138 + app/routes/admin.tsx | 7 + .../__tests__/sync-tournament-results.test.ts | 577 ++ app/services/sync-tournament-results.ts | 138 + database/schema.ts | 51 +- docs/agents/database.md | 25 +- docs/agents/domain-models.md | 17 +- drizzle/0089_lonely_tigra.sql | 2 + drizzle/0090_powerful_redwing.sql | 1 + drizzle/meta/0089_snapshot.json | 5715 +++++++++++++++++ drizzle/meta/0090_snapshot.json | 5603 ++++++++++++++++ drizzle/meta/_journal.json | 14 + package.json | 1 - .../backfill-canonical-layer.test.ts | 450 -- scripts/backfill-canonical-layer.ts | 358 -- scripts/backfill-cli.ts | 82 - .../__tests__/match-tournament.test.ts | 44 - scripts/backfill/match-tournament.ts | 57 - 30 files changed, 13166 insertions(+), 1112 deletions(-) create mode 100644 app/lib/tournament-identity.ts create mode 100644 app/routes/__tests__/admin.tournaments.$id.test.tsx create mode 100644 app/routes/admin.tournaments.$id.tsx create mode 100644 app/routes/admin.tournaments._index.tsx create mode 100644 app/services/__tests__/sync-tournament-results.test.ts create mode 100644 app/services/sync-tournament-results.ts create mode 100644 drizzle/0089_lonely_tigra.sql create mode 100644 drizzle/0090_powerful_redwing.sql create mode 100644 drizzle/meta/0089_snapshot.json create mode 100644 drizzle/meta/0090_snapshot.json delete mode 100644 scripts/__tests__/backfill-canonical-layer.test.ts delete mode 100644 scripts/backfill-canonical-layer.ts delete mode 100644 scripts/backfill-cli.ts delete mode 100644 scripts/backfill/__tests__/match-tournament.test.ts delete mode 100644 scripts/backfill/match-tournament.ts diff --git a/app/components/BatchResultEntry.tsx b/app/components/BatchResultEntry.tsx index 9152639..6f2ccd3 100644 --- a/app/components/BatchResultEntry.tsx +++ b/app/components/BatchResultEntry.tsx @@ -46,12 +46,18 @@ interface BatchResultEntryProps { participants: Participant[]; sportsSeasonId: string; existingResultParticipantIds: Set; + /** + * 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({ participants, sportsSeasonId, existingResultParticipantIds, + intent = "batch-add-results", }: BatchResultEntryProps) { const [stage, setStage] = useState("idle"); const [pasteText, setPasteText] = useState(""); @@ -172,7 +178,7 @@ export function BatchResultEntry({ placement: r.placement, })); const formData = new FormData(); - formData.set("intent", "batch-add-results"); + formData.set("intent", intent); formData.set("results", JSON.stringify(results)); saveFetcher.submit(formData, { method: "post" }); } diff --git a/app/lib/tournament-identity.ts b/app/lib/tournament-identity.ts new file mode 100644 index 0000000..1cce590 --- /dev/null +++ b/app/lib/tournament-identity.ts @@ -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 }; +} diff --git a/app/models/__tests__/surface-elo.test.ts b/app/models/__tests__/surface-elo.test.ts index 307a87a..2db14a9 100644 --- a/app/models/__tests__/surface-elo.test.ts +++ b/app/models/__tests__/surface-elo.test.ts @@ -42,10 +42,10 @@ describe("getSurfaceEloMap", () => { expect(result.size).toBe(0); }); - it("returns a Map keyed by participantId", async () => { + it("returns a Map keyed by seasonParticipantId", async () => { mockDb.where.mockResolvedValue([ - { participantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, - { participantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null }, + { seasonParticipantId: "p1", worldRanking: 1, eloHard: 1800, eloClay: 1700, eloGrass: 1600 }, + { seasonParticipantId: "p2", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: null }, ]); const result = await getSurfaceEloMap("season-1"); expect(result.size).toBe(2); @@ -55,7 +55,7 @@ describe("getSurfaceEloMap", () => { it("preserves null surface Elos (does not coerce to 0)", async () => { 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 entry = result.get("p1"); @@ -72,14 +72,21 @@ describe("batchUpsertSurfaceElos", () => { expect(mockDb.insert).not.toHaveBeenCalled(); }); - it("calls insert with the correct values", async () => { - const inputs = [ + it("writes canonical rows using resolved canonical participant ids", async () => { + // 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: "p2", sportsSeasonId: "s1", worldRanking: null, eloHard: null, eloClay: 1750, eloGrass: undefined }, - ]; - await batchUpsertSurfaceElos(inputs); + ]); + expect(mockDb.insert).toHaveBeenCalledOnce(); expect(mockDb.values).toHaveBeenCalledOnce(); + expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce(); const passedValues = mockDb.values.mock.calls[0][0] as Array<{ participantId: string; @@ -88,17 +95,21 @@ describe("batchUpsertSurfaceElos", () => { eloGrass: number | null; }>; 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[1].participantId).toBe("canon-2"); expect(passedValues[1].eloHard).toBeNull(); 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([ { participantId: "p1", sportsSeasonId: "s1", worldRanking: 5, eloHard: 2000 }, ]); - expect(mockDb.onConflictDoUpdate).toHaveBeenCalledOnce(); + + expect(mockDb.insert).not.toHaveBeenCalled(); }); }); diff --git a/app/models/scoring-event.ts b/app/models/scoring-event.ts index e5dfb15..f0ea0b2 100644 --- a/app/models/scoring-event.ts +++ b/app/models/scoring-event.ts @@ -24,6 +24,8 @@ export interface CreateScoringEventData { eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; + /** Required when isQualifyingEvent=true (DB check constraint). */ + tournamentId?: string; // Template system fields (Phase 2.6) bracketTemplateId?: string; scoringStartsAtRound?: string; @@ -59,6 +61,7 @@ export async function createScoringEvent( eventStartsAt: data.eventStartsAt, eventType: data.eventType, isQualifyingEvent: data.isQualifyingEvent || false, + tournamentId: data.tournamentId, bracketTemplateId: data.bracketTemplateId, scoringStartsAtRound: data.scoringStartsAtRound, isComplete: false, @@ -743,7 +746,7 @@ export async function getEventsForDates( */ export async function bulkCreateScoringEvents( 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 ) { const db = providedDb || database(); @@ -757,6 +760,7 @@ export async function bulkCreateScoringEvents( eventStartsAt: e.eventStartsAt, eventType: e.eventType, isQualifyingEvent: e.isQualifyingEvent ?? false, + tournamentId: e.tournamentId, isComplete: false, })); diff --git a/app/models/season-participant.ts b/app/models/season-participant.ts index 5d4850c..7655700 100644 --- a/app/models/season-participant.ts +++ b/app/models/season-participant.ts @@ -7,18 +7,86 @@ export type NewParticipant = typeof schema.seasonParticipants.$inferInsert; export async function createParticipant(data: NewParticipant): Promise { 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 .insert(schema.seasonParticipants) - .values(data) + .values({ ...data, participantId: canonicalId }) .returning(); return participant; } export async function createManyParticipants(data: NewParticipant[]): Promise { 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 .insert(schema.seasonParticipants) - .values(data) + .values(enriched) .returning(); } diff --git a/app/models/surface-elo.ts b/app/models/surface-elo.ts index edb4a27..b7d903c 100644 --- a/app/models/surface-elo.ts +++ b/app/models/surface-elo.ts @@ -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). - * Each participant in a sports season can have separate Elo ratings for hard, - * clay, and grass courts. + * Surface Elos are stored in the canonical `participant_surface_elos` table, + * keyed by canonical participant id. The admin UI still works in terms of + * 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 { seasonParticipantSurfaceElos, seasonParticipants } from "~/database/schema"; -import { eq, sql } from "drizzle-orm"; +import { participantSurfaceElos, seasonParticipants } from "~/database/schema"; +import { eq, inArray, sql } from "drizzle-orm"; 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. - * Returns one record per participant (seasonParticipants with no Elo record are excluded). + * Load surface Elos for a sports season's roster, joined with the per-window + * 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( - sportsSeasonId: string + sportsSeasonId: string, ): Promise { const db = database(); - const rows = await db + return await db .select({ - id: seasonParticipantSurfaceElos.id, - participantId: seasonParticipantSurfaceElos.participantId, - sportsSeasonId: seasonParticipantSurfaceElos.sportsSeasonId, - worldRanking: seasonParticipantSurfaceElos.worldRanking, - eloHard: seasonParticipantSurfaceElos.eloHard, - eloClay: seasonParticipantSurfaceElos.eloClay, - eloGrass: seasonParticipantSurfaceElos.eloGrass, - updatedAt: seasonParticipantSurfaceElos.updatedAt, + id: participantSurfaceElos.id, + participantId: seasonParticipants.id, + sportsSeasonId: seasonParticipants.sportsSeasonId, + worldRanking: participantSurfaceElos.worldRanking, + eloHard: participantSurfaceElos.eloHard, + eloClay: participantSurfaceElos.eloClay, + eloGrass: participantSurfaceElos.eloGrass, + updatedAt: participantSurfaceElos.updatedAt, participantName: seasonParticipants.name, }) - .from(seasonParticipantSurfaceElos) - .innerJoin(seasonParticipants, eq(seasonParticipantSurfaceElos.participantId, seasonParticipants.id)) - .where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId)) + .from(seasonParticipants) + .innerJoin( + participantSurfaceElos, + eq(participantSurfaceElos.participantId, seasonParticipants.participantId), + ) + .where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId)) .orderBy(seasonParticipants.name); - - return rows; } /** - * Upsert surface Elo ratings for a batch of seasonParticipants. - * Uses INSERT ... ON CONFLICT DO UPDATE so all three surface columns are - * overwritten atomically — the admin always submits all three values. + * Upsert surface Elos for a batch of season_participants. Writes to the + * canonical `participant_surface_elos` table; callers pass season_participant + * 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( - inputs: SurfaceEloInput[] + inputs: SurfaceEloInput[], ): Promise { if (inputs.length === 0) return; const db = database(); 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 - .insert(seasonParticipantSurfaceElos) - .values( - inputs.map(({ participantId, sportsSeasonId, worldRanking, eloHard, eloClay, eloGrass }) => ({ - participantId, - sportsSeasonId, - worldRanking: worldRanking ?? null, - eloHard: eloHard ?? null, - eloClay: eloClay ?? null, - eloGrass: eloGrass ?? null, - updatedAt: now, - })) - ) + .insert(participantSurfaceElos) + .values(canonicalRows.map(({ canonicalId, ...rest }) => ({ + participantId: canonicalId, + ...rest, + updatedAt: now, + }))) .onConflictDoUpdate({ - target: [seasonParticipantSurfaceElos.participantId, seasonParticipantSurfaceElos.sportsSeasonId], + target: [participantSurfaceElos.participantId], set: { worldRanking: sql`excluded.world_ranking`, 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. - * Participants with no record are absent from the map (simulator falls back to 1500). + * Build a map keyed by season_participant.id → surface Elo values, sourced + * 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( - sportsSeasonId: string + sportsSeasonId: string, ): Promise> { const db = database(); const rows = await db .select({ - participantId: seasonParticipantSurfaceElos.participantId, - worldRanking: seasonParticipantSurfaceElos.worldRanking, - eloHard: seasonParticipantSurfaceElos.eloHard, - eloClay: seasonParticipantSurfaceElos.eloClay, - eloGrass: seasonParticipantSurfaceElos.eloGrass, + seasonParticipantId: seasonParticipants.id, + worldRanking: participantSurfaceElos.worldRanking, + eloHard: participantSurfaceElos.eloHard, + eloClay: participantSurfaceElos.eloClay, + eloGrass: participantSurfaceElos.eloGrass, }) - .from(seasonParticipantSurfaceElos) - .where(eq(seasonParticipantSurfaceElos.sportsSeasonId, sportsSeasonId)); + .from(seasonParticipants) + .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, eloHard: r.eloHard, eloClay: r.eloClay, diff --git a/app/models/tournament.ts b/app/models/tournament.ts index e26659c..2bf7c51 100644 --- a/app/models/tournament.ts +++ b/app/models/tournament.ts @@ -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 * as schema from "~/database/schema"; @@ -80,6 +80,50 @@ export async function upsertTournament( 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( id: string, status: TournamentStatus diff --git a/app/routes.ts b/app/routes.ts index 9b784d6..9ba6e9b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -129,6 +129,8 @@ export default [ "routes/admin.sports-seasons.$id.clone.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/new", "routes/admin.templates.new.tsx"), route("templates/:id", "routes/admin.templates.$id.tsx"), diff --git a/app/routes/__tests__/admin.tournaments.$id.test.tsx b/app/routes/__tests__/admin.tournaments.$id.test.tsx new file mode 100644 index 0000000..1c9059f --- /dev/null +++ b/app/routes/__tests__/admin.tournaments.$id.test.tsx @@ -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) { + 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[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, + }); + }); +}); diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts index c1a997b..7e9f0e2 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -16,7 +16,7 @@ import { type CreateEventResultData, type UpdateEventResultData, } 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 { upsertParticipantSeasonResult, @@ -27,6 +27,8 @@ import { getQPStandings, getQPConfig } from "~/models/qualifying-points"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; 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) { const sportsSeason = await findSportsSeasonById(params.id); @@ -372,22 +374,49 @@ export async function action({ request, params }: Route.ActionArgs) { } try { - const existingResults = await getEventResults(params.eventId); - const existingIds = new Set(existingResults.map((r) => r.seasonParticipantId)); - const newResults = filterNewResults(incoming, existingIds); - - if (newResults.length > 0) { - await createEventResultsBulk( - newResults.map((r) => ({ - scoringEventId: params.eventId, - participantId: r.participantId, - placement: r.placement, - })) - ); + // If this scoring event is linked to a canonical tournament, write + // results to the canonical layer and fan out via syncTournamentResults. + // This keeps sibling windows (if any) in sync automatically. + const scoringEvent = await database().query.scoringEvents.findFirst({ + where: eq(schema.scoringEvents.id, params.eventId), + }); + if (!scoringEvent) { + return { error: "Scoring event not found" }; } + // 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 { - 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) { logger.error("Error saving batch results:", error); diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts index ff103ac..30c0094 100644 --- a/app/routes/admin.sports-seasons.$id.events.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.server.ts @@ -11,6 +11,8 @@ import { } from "~/models/scoring-event"; import { getQPStandings } from "~/models/qualifying-points"; import { finalizeQualifyingPoints } from "~/models/scoring-calculator"; +import { upsertTournament } from "~/models/tournament"; +import { extractTournamentIdentity } from "~/lib/tournament-identity"; 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" }; } + // 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 { await bulkCreateScoringEvents(params.id, validEvents); return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` }; @@ -178,6 +205,31 @@ export async function action({ request, params }: Route.ActionArgs) { 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 { const event = await createScoringEvent(eventData); return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`); diff --git a/app/routes/admin.tournaments.$id.tsx b/app/routes/admin.tournaments.$id.tsx new file mode 100644 index 0000000..d91e49b --- /dev/null +++ b/app/routes/admin.tournaments.$id.tsx @@ -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(); + + // 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 ( +
+
+
+ +
+

{tournament.name}

+ + {tournament.status} + +
+

+ {tournament.year} + {tournament.location ? ` — ${tournament.location}` : ""} + {tournament.surface ? ` — ${tournament.surface}` : ""} +

+
+ + {liveReport && ( + + + + + Synced to {liveReport.windowsSynced}{" "} + {liveReport.windowsSynced === 1 ? "window" : "windows"} + + + Canonical results were fanned out to every linked scoring + window. + + + {liveReport.failures.length > 0 && ( + +
+
+ + {liveReport.failures.length}{" "} + {liveReport.failures.length === 1 ? "window" : "windows"}{" "} + failed to sync +
+
+ {liveReport.failures.map((f) => ( +
+
+
+ event: {f.scoringEventId} +
+
+ season: {f.sportsSeasonId} +
+
{f.error}
+
+ + + + + +
+ ))} +
+
+
+ )} +
+ )} + + {actionData?.error && ( +
+ {actionData.error} +
+ )} + +
+ + + Current Results + + {results.length}{" "} + {results.length === 1 ? "result" : "results"} recorded + + + + {results.length === 0 ? ( +

+ No results recorded yet. Paste a ranked list to the right to + import. +

+ ) : ( + + + + Placement + Participant + + + + {results.map((r) => { + const p = participantById.get(r.participantId); + return ( + + + {r.placement ?? "—"} + + + {p?.name ?? ( + + {r.participantId} + + )} + + + ); + })} + +
+ )} +
+
+ + ({ + id: p.id, + name: p.name, + }))} + sportsSeasonId="" + existingResultParticipantIds={ + new Set(results.map((r) => r.participantId)) + } + intent="batch-upsert-results" + /> +
+
+
+ ); +} diff --git a/app/routes/admin.tournaments._index.tsx b/app/routes/admin.tournaments._index.tsx new file mode 100644 index 0000000..5753b05 --- /dev/null +++ b/app/routes/admin.tournaments._index.tsx @@ -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 ( +
+
+
+

Tournaments

+

+ Canonical tournaments across all sports. Enter results once here + and they fan out to every linked scoring window. +

+
+
+ + {grouped.length === 0 ? ( + + + +

+ No tournaments yet +

+

+ Canonical tournaments are created automatically by the backfill + or data-sync flows. +

+
+
+ ) : ( +
+

+ {totalCount} {totalCount === 1 ? "tournament" : "tournaments"}{" "} + across {grouped.length}{" "} + {grouped.length === 1 ? "sport" : "sports"} +

+ {grouped.map((group) => ( + + + {group.sport.name} + + {group.tournaments.length}{" "} + {group.tournaments.length === 1 + ? "tournament" + : "tournaments"} + + + + + + + Name + Year + Starts + Status + Actions + + + + {group.tournaments.map((t) => ( + + {t.name} + {t.year} + + {t.startsAt + ? new Date(t.startsAt).toLocaleDateString() + : "—"} + + + + {t.status} + + + + + + + ))} + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index e56e172..53bd4f6 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -10,6 +10,7 @@ import { Calendar, FolderKanban, RefreshCw, + Award, } from "lucide-react"; export function meta(): Route.MetaDescriptors { @@ -59,6 +60,12 @@ export default function AdminLayout() { Sports Seasons +