Spec and phased implementation plans for eliminating data duplication across overlapping qualifying-points windows (golf, tennis, CS2) by introducing canonical tournaments, participants, tournament_results, and surface-Elo tables above the existing per-window layer. - Phase 1a: rename existing per-window tables to season_* prefix - Phase 1b: create canonical tables + nullable FKs - Phase 2: backfill canonical from existing in-flight windows - Phase 3: cutover - sync service, admin UI, simulator read switch - Phase 4: cleanup - drop deprecated tables and routes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1272 lines
44 KiB
Markdown
1272 lines
44 KiB
Markdown
# Phase 3 — Canonical Cutover Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Make the canonical layer the source of truth. Flip FKs to NOT NULL, implement `syncTournamentResults`, add the canonical admin result-entry route, switch the tennis simulator's surface-Elo read path to canonical, and convert old per-window result-entry routes to back-compat wrappers.
|
|
|
|
**Architecture:** Canonical writes fan out to every window that contains a given tournament. Surface-Elo reads come from canonical. Old routes stay as thin wrappers (call `upsertTournamentResult` + `syncTournamentResults`) — removal is Phase 4. A feature flag gates the new admin route until the cutover window.
|
|
|
|
**Tech Stack:** TypeScript, React Router 7, Drizzle, Vitest, Cypress.
|
|
|
|
**Prerequisite:** Phase 2 complete. Tag `phase-2-complete` exists. All canonical tables populated in prod.
|
|
|
|
**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 3 section.
|
|
|
|
---
|
|
|
|
### Task 1: Enforce NOT NULL on the two new FK columns
|
|
|
|
**Why:** Phase 2 backfilled every row. Now lock it in to prevent future NULLs.
|
|
|
|
**Files:**
|
|
- Modify: `database/schema.ts`
|
|
- Create: `drizzle/XXXX_lock_canonical_fks.sql`
|
|
|
|
- [ ] **Step 1: Update schema**
|
|
|
|
In `database/schema.ts`:
|
|
|
|
```typescript
|
|
// In scoringEvents:
|
|
tournamentId: uuid("tournament_id").notNull().references(() => tournaments.id, {
|
|
onDelete: "restrict", // strengthened from set null
|
|
}),
|
|
|
|
// In seasonParticipants:
|
|
participantId: uuid("participant_id").notNull().references(() => participants.id, {
|
|
onDelete: "restrict",
|
|
}),
|
|
```
|
|
|
|
- [ ] **Step 2: Generate migration**
|
|
|
|
Run: `npm run db:generate -- --name=lock_canonical_fks`
|
|
Expected: Migration with two `ALTER COLUMN ... SET NOT NULL` and FK constraint changes.
|
|
|
|
- [ ] **Step 3: Pre-flight sanity check in prod snapshot**
|
|
|
|
Run:
|
|
```bash
|
|
psql $DATABASE_URL -c "SELECT count(*) FROM scoring_events WHERE tournament_id IS NULL;"
|
|
psql $DATABASE_URL -c "SELECT count(*) FROM season_participants WHERE participant_id IS NULL;"
|
|
```
|
|
|
|
Expected: Both zero. If non-zero for any `qualifying_points` sport, Phase 2 was incomplete — halt and fix first. For team-sport `scoring_events`, `tournament_id IS NULL` is expected because they weren't backfilled; we need to scope the NOT NULL constraint, OR restrict backfill scope.
|
|
|
|
**Scoping decision:** Only qualifying-points seasons have tournaments. Team-sport scoring events (NHL playoff games, NBA, etc.) don't map to "tournaments" in our canonical sense.
|
|
|
|
Change approach: **Do not make `scoring_events.tournament_id` NOT NULL globally.** Instead, enforce it via a partial index / check constraint:
|
|
|
|
Replace the schema edit for `scoringEvents.tournamentId`:
|
|
|
|
```typescript
|
|
tournamentId: uuid("tournament_id").references(() => tournaments.id, {
|
|
onDelete: "restrict",
|
|
}),
|
|
```
|
|
(Stay nullable.)
|
|
|
|
And append to the table config:
|
|
|
|
```typescript
|
|
// (t) => ({
|
|
// ... existing index definitions ...
|
|
// qualifyingEventsRequireTournament: check(
|
|
// "scoring_events_qualifying_require_tournament",
|
|
// sql`NOT is_qualifying_event OR tournament_id IS NOT NULL`,
|
|
// ),
|
|
// })
|
|
```
|
|
|
|
Drizzle supports `check` constraints via `sql` templating. Adjust imports accordingly.
|
|
|
|
For `seasonParticipants.participantId`: every `season_participants` row belongs to a `sports_season`, which has a `scoring_pattern`. We only require it for `qualifying_points` seasons. Implement as a check constraint using a scalar subquery is awkward in Postgres; instead, enforce in application code at insert time (with a test), and leave the DB column nullable. **Document this decision in the schema file as a comment on the column.**
|
|
|
|
- [ ] **Step 4: Regenerate migration with the corrected approach**
|
|
|
|
Remove any previous generated migration for this change, then:
|
|
|
|
Run: `npm run db:generate -- --name=add_qualifying_tournament_check`
|
|
Expected: Migration adds the check constraint only.
|
|
|
|
- [ ] **Step 5: Apply and test**
|
|
|
|
Run: `npm run db:migrate`
|
|
Verify in psql that the check constraint exists and blocks inserting a `scoring_events` row with `is_qualifying_event=true, tournament_id=NULL`.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add database/schema.ts drizzle/
|
|
git commit -m "migration: require tournament_id for qualifying scoring_events"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Implement `syncTournamentResults` service (TDD)
|
|
|
|
**Files:**
|
|
- Create: `app/services/sync-tournament-results.ts`
|
|
- Create: `app/services/__tests__/sync-tournament-results.test.ts`
|
|
|
|
- [ ] **Step 1: Write the first failing test — single window, full roster match**
|
|
|
|
```typescript
|
|
// app/services/__tests__/sync-tournament-results.test.ts
|
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { db } from "~/db/context";
|
|
import {
|
|
tournaments,
|
|
participants,
|
|
tournamentResults,
|
|
scoringEvents,
|
|
seasonParticipants,
|
|
sportsSeasons,
|
|
sports,
|
|
eventResults,
|
|
qualifyingPointConfig,
|
|
} from "database/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { syncTournamentResults } from "../sync-tournament-results";
|
|
|
|
async function seedTennisWindowWithWimbledon(): Promise<{
|
|
sportId: string;
|
|
ssId: string;
|
|
wimbledon: { id: string };
|
|
eventId: string;
|
|
canonicalIds: { djokovic: string; alcaraz: string };
|
|
seasonParticipantIds: { djokovic: string; alcaraz: string };
|
|
}> {
|
|
const [sport] = await db.insert(sports).values({
|
|
name: "Tennis",
|
|
type: "individual",
|
|
simulatorType: "tennis_qualifying_points",
|
|
}).returning();
|
|
|
|
const [canonDjokovic] = await db.insert(participants).values({
|
|
sportId: sport.id,
|
|
name: "Novak Djokovic",
|
|
}).returning();
|
|
const [canonAlcaraz] = await db.insert(participants).values({
|
|
sportId: sport.id,
|
|
name: "Carlos Alcaraz",
|
|
}).returning();
|
|
|
|
const [wimbledon] = await db.insert(tournaments).values({
|
|
sportId: sport.id,
|
|
name: "Wimbledon",
|
|
year: 2026,
|
|
}).returning();
|
|
|
|
const [ss] = await db.insert(sportsSeasons).values({
|
|
sportId: sport.id,
|
|
year: 2026,
|
|
scoringPattern: "qualifying_points",
|
|
totalMajors: 4,
|
|
}).returning();
|
|
|
|
const [scoringEv] = await db.insert(scoringEvents).values({
|
|
sportsSeasonId: ss.id,
|
|
name: "Wimbledon",
|
|
eventDate: "2026-07-12",
|
|
eventType: "major_tournament",
|
|
isQualifyingEvent: true,
|
|
tournamentId: wimbledon.id,
|
|
}).returning();
|
|
|
|
const [spDjokovic] = await db.insert(seasonParticipants).values({
|
|
sportsSeasonId: ss.id,
|
|
name: "Novak Djokovic",
|
|
participantId: canonDjokovic.id,
|
|
}).returning();
|
|
const [spAlcaraz] = await db.insert(seasonParticipants).values({
|
|
sportsSeasonId: ss.id,
|
|
name: "Carlos Alcaraz",
|
|
participantId: canonAlcaraz.id,
|
|
}).returning();
|
|
|
|
// Seed QP point config: 1st=100, 2nd=50, default 0
|
|
await db.insert(qualifyingPointConfig).values([
|
|
{ sportsSeasonId: ss.id, placement: 1, points: "100.00" },
|
|
{ sportsSeasonId: ss.id, placement: 2, points: "50.00" },
|
|
]);
|
|
|
|
return {
|
|
sportId: sport.id,
|
|
ssId: ss.id,
|
|
wimbledon: { id: wimbledon.id },
|
|
eventId: scoringEv.id,
|
|
canonicalIds: { djokovic: canonDjokovic.id, alcaraz: canonAlcaraz.id },
|
|
seasonParticipantIds: { djokovic: spDjokovic.id, alcaraz: spAlcaraz.id },
|
|
};
|
|
}
|
|
|
|
describe("syncTournamentResults", () => {
|
|
beforeEach(async () => {
|
|
// Broad wipe order matters (FKs)
|
|
await db.delete(tournamentResults);
|
|
await db.delete(eventResults);
|
|
await db.delete(qualifyingPointConfig);
|
|
await db.delete(seasonParticipants);
|
|
await db.delete(scoringEvents);
|
|
await db.delete(sportsSeasons);
|
|
await db.delete(participants);
|
|
await db.delete(tournaments);
|
|
await db.delete(sports);
|
|
});
|
|
|
|
it("single window: creates event_results from tournament_results and processes QP", async () => {
|
|
const seed = await seedTennisWindowWithWimbledon();
|
|
|
|
// Enter canonical results
|
|
await db.insert(tournamentResults).values([
|
|
{ tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.djokovic, placement: 1 },
|
|
{ tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.alcaraz, placement: 2 },
|
|
]);
|
|
|
|
const report = await syncTournamentResults(seed.wimbledon.id);
|
|
expect(report.windowsSynced).toBe(1);
|
|
expect(report.windowsFailed).toBe(0);
|
|
|
|
const ers = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));
|
|
expect(ers).toHaveLength(2);
|
|
|
|
const djokER = ers.find((r) => r.seasonParticipantId === seed.seasonParticipantIds.djokovic)!;
|
|
expect(djokER.placement).toBe(1);
|
|
expect(djokER.qualifyingPointsAwarded).toBe("100.00");
|
|
|
|
const alcER = ers.find((r) => r.seasonParticipantId === seed.seasonParticipantIds.alcaraz)!;
|
|
expect(alcER.placement).toBe(2);
|
|
expect(alcER.qualifyingPointsAwarded).toBe("50.00");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run failing test**
|
|
|
|
Run: `npm run test:run -- app/services/__tests__/sync-tournament-results.test.ts`
|
|
Expected: FAIL (module missing).
|
|
|
|
- [ ] **Step 3: Implement the service**
|
|
|
|
```typescript
|
|
// app/services/sync-tournament-results.ts
|
|
import { db } from "~/db/context";
|
|
import {
|
|
tournamentResults,
|
|
scoringEvents,
|
|
seasonParticipants,
|
|
eventResults,
|
|
sportsSeasons,
|
|
} from "database/schema";
|
|
import { eq, and, inArray } from "drizzle-orm";
|
|
import { processQualifyingEvent } from "~/models/scoring-calculator";
|
|
|
|
export interface SyncReport {
|
|
tournamentId: string;
|
|
windowsSynced: number;
|
|
windowsFailed: number;
|
|
failures: Array<{ scoringEventId: string; sportsSeasonId: string; error: string }>;
|
|
}
|
|
|
|
export async function syncTournamentResults(tournamentId: string): Promise<SyncReport> {
|
|
const report: SyncReport = {
|
|
tournamentId,
|
|
windowsSynced: 0,
|
|
windowsFailed: 0,
|
|
failures: [],
|
|
};
|
|
|
|
// Load canonical results for this tournament
|
|
const canonicalResults = await db
|
|
.select()
|
|
.from(tournamentResults)
|
|
.where(eq(tournamentResults.tournamentId, tournamentId));
|
|
|
|
// Load every scoring event that points at this tournament
|
|
const linkedEvents = await db
|
|
.select()
|
|
.from(scoringEvents)
|
|
.where(eq(scoringEvents.tournamentId, tournamentId));
|
|
|
|
for (const ev of linkedEvents) {
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
// All season_participants for this window's sports_season
|
|
const rosters = await tx
|
|
.select()
|
|
.from(seasonParticipants)
|
|
.where(eq(seasonParticipants.sportsSeasonId, ev.sportsSeasonId));
|
|
|
|
// Upsert event_results for every canonical result where the
|
|
// canonical participant is in the window's roster.
|
|
for (const cr of canonicalResults) {
|
|
const sp = rosters.find((r) => r.participantId === cr.participantId);
|
|
if (!sp) continue;
|
|
|
|
const [existing] = await tx
|
|
.select()
|
|
.from(eventResults)
|
|
.where(
|
|
and(
|
|
eq(eventResults.scoringEventId, ev.id),
|
|
eq(eventResults.seasonParticipantId, sp.id),
|
|
),
|
|
);
|
|
|
|
if (existing) {
|
|
await tx
|
|
.update(eventResults)
|
|
.set({
|
|
placement: cr.placement,
|
|
rawScore: cr.rawScore,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(eventResults.id, existing.id));
|
|
} else {
|
|
await tx.insert(eventResults).values({
|
|
scoringEventId: ev.id,
|
|
seasonParticipantId: sp.id,
|
|
placement: cr.placement,
|
|
rawScore: cr.rawScore,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Apply the existing "0-QP filler" pass: every season participant
|
|
// without an eventResults row gets one with placement=null, QP=0.
|
|
// This preserves the behavior from the batch-qualifying-results design.
|
|
const afterRows = await tx
|
|
.select()
|
|
.from(eventResults)
|
|
.where(eq(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(eventResults).values({
|
|
scoringEventId: ev.id,
|
|
seasonParticipantId: sp.id,
|
|
placement: null,
|
|
qualifyingPointsAwarded: "0",
|
|
});
|
|
}
|
|
|
|
// Delegate to the existing scoring engine (idempotent: replaces QP)
|
|
await processQualifyingEvent(ev.id, { tx });
|
|
});
|
|
report.windowsSynced += 1;
|
|
} catch (e: any) {
|
|
report.windowsFailed += 1;
|
|
report.failures.push({
|
|
scoringEventId: ev.id,
|
|
sportsSeasonId: ev.sportsSeasonId,
|
|
error: String(e?.message ?? e),
|
|
});
|
|
}
|
|
}
|
|
|
|
return report;
|
|
}
|
|
```
|
|
|
|
**Note:** `processQualifyingEvent` currently does not accept a `tx` option. A preparatory micro-refactor may be required — see Task 2b below.
|
|
|
|
- [ ] **Step 4: Run the first test**
|
|
|
|
Run: `npm run test:run -- app/services/__tests__/sync-tournament-results.test.ts`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/services/
|
|
git commit -m "feat: syncTournamentResults service (single-window case)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2b: Teach `processQualifyingEvent` to accept an external transaction
|
|
|
|
**Files:**
|
|
- Modify: `app/models/scoring-calculator.ts:584` (and signature)
|
|
- Modify: `app/services/sync-tournament-results.ts` (if needed after signature change)
|
|
|
|
- [ ] **Step 1: Read the current signature**
|
|
|
|
Open `app/models/scoring-calculator.ts` around line 584. Note the current signature (likely `async function processQualifyingEvent(scoringEventId: string): Promise<...>`).
|
|
|
|
- [ ] **Step 2: Add optional `{ tx }` parameter**
|
|
|
|
```typescript
|
|
export async function processQualifyingEvent(
|
|
scoringEventId: string,
|
|
opts?: { tx?: typeof db },
|
|
): Promise<ProcessQualifyingEventResult> {
|
|
const q = opts?.tx ?? db;
|
|
// ... replace every `db.` inside the function body with `q.`
|
|
}
|
|
```
|
|
|
|
If the function is long, extract the body to a helper `processQualifyingEventWith(q, scoringEventId)` and have the outer function call it.
|
|
|
|
- [ ] **Step 3: Add a targeted test**
|
|
|
|
```typescript
|
|
// app/models/__tests__/scoring-calculator.tx.test.ts (new)
|
|
// Verifies that passing a tx that gets rolled back reverts QP writes.
|
|
import { describe, it, expect } from "vitest";
|
|
import { db } from "~/db/context";
|
|
import { processQualifyingEvent } from "../scoring-calculator";
|
|
// ... seed, run within a rollback transaction, verify state is unchanged.
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tx test + all existing scoring-calculator tests**
|
|
|
|
Run: `npm run test:run -- app/models/__tests__/scoring-calculator`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/models/scoring-calculator.ts app/models/__tests__/scoring-calculator.tx.test.ts
|
|
git commit -m "refactor(scoring-calculator): accept optional tx for composability"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Sync — two windows, overlapping rosters
|
|
|
|
**Files:**
|
|
- Modify: `app/services/__tests__/sync-tournament-results.test.ts`
|
|
|
|
- [ ] **Step 1: Add failing test**
|
|
|
|
```typescript
|
|
it("two windows sharing a tournament: only matching participants get event_results per window", async () => {
|
|
const base = await seedTennisWindowWithWimbledon();
|
|
|
|
// Create a second tennis window (June 2026) that also includes Wimbledon 2026
|
|
// but has a DIFFERENT roster — Alcaraz only (no Djokovic).
|
|
const [ss2] = await db.insert(sportsSeasons).values({
|
|
sportId: base.sportId,
|
|
year: 2026,
|
|
scoringPattern: "qualifying_points",
|
|
totalMajors: 4,
|
|
}).returning();
|
|
|
|
const [ev2] = await db.insert(scoringEvents).values({
|
|
sportsSeasonId: ss2.id,
|
|
name: "Wimbledon",
|
|
eventDate: "2026-07-12",
|
|
eventType: "major_tournament",
|
|
isQualifyingEvent: true,
|
|
tournamentId: base.wimbledon.id,
|
|
}).returning();
|
|
|
|
const [spAlcaraz2] = await db.insert(seasonParticipants).values({
|
|
sportsSeasonId: ss2.id,
|
|
name: "Carlos Alcaraz",
|
|
participantId: base.canonicalIds.alcaraz,
|
|
}).returning();
|
|
|
|
await db.insert(qualifyingPointConfig).values([
|
|
{ sportsSeasonId: ss2.id, placement: 1, points: "100.00" },
|
|
{ sportsSeasonId: ss2.id, placement: 2, points: "50.00" },
|
|
]);
|
|
|
|
await db.insert(tournamentResults).values([
|
|
{ tournamentId: base.wimbledon.id, participantId: base.canonicalIds.djokovic, placement: 1 },
|
|
{ tournamentId: base.wimbledon.id, participantId: base.canonicalIds.alcaraz, placement: 2 },
|
|
]);
|
|
|
|
const report = await syncTournamentResults(base.wimbledon.id);
|
|
expect(report.windowsSynced).toBe(2);
|
|
|
|
// Window 1: both participants present
|
|
const w1Results = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, base.eventId));
|
|
expect(w1Results).toHaveLength(2);
|
|
|
|
// Window 2: only Alcaraz present with real placement; Djokovic is absent (not in roster) → 0-QP filler NOT created for him either
|
|
const w2Results = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, ev2.id));
|
|
const realResults = w2Results.filter((r) => r.placement !== null);
|
|
expect(realResults).toHaveLength(1);
|
|
expect(realResults[0].seasonParticipantId).toBe(spAlcaraz2.id);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run failing test**
|
|
|
|
Expected: Likely PASS already if the implementation is correct. If it fails, investigate — the service should iterate per linked scoring event and respect each window's roster independently.
|
|
|
|
- [ ] **Step 3: If fail, fix the service**
|
|
|
|
Trace the bug. Common cause: the service cached rosters between loop iterations. Fix.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add app/services/
|
|
git commit -m "test: two-window sync respects per-window rosters"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Sync — correction path and idempotency
|
|
|
|
**Files:**
|
|
- Modify: `app/services/__tests__/sync-tournament-results.test.ts`
|
|
|
|
- [ ] **Step 1: Add failing tests**
|
|
|
|
```typescript
|
|
it("re-running sync with unchanged results is idempotent", async () => {
|
|
const seed = await seedTennisWindowWithWimbledon();
|
|
await db.insert(tournamentResults).values([
|
|
{ tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.djokovic, placement: 1 },
|
|
{ tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.alcaraz, placement: 2 },
|
|
]);
|
|
await syncTournamentResults(seed.wimbledon.id);
|
|
const before = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));
|
|
|
|
await syncTournamentResults(seed.wimbledon.id);
|
|
const after = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));
|
|
|
|
expect(after).toHaveLength(before.length);
|
|
expect(after.map((r) => [r.seasonParticipantId, r.placement, r.qualifyingPointsAwarded]))
|
|
.toEqual(before.map((r) => [r.seasonParticipantId, r.placement, r.qualifyingPointsAwarded]));
|
|
});
|
|
|
|
it("correction flow: editing a tournament_results placement propagates to every window", async () => {
|
|
const seed = await seedTennisWindowWithWimbledon();
|
|
await db.insert(tournamentResults).values([
|
|
{ tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.djokovic, placement: 1 },
|
|
{ tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.alcaraz, placement: 2 },
|
|
]);
|
|
await syncTournamentResults(seed.wimbledon.id);
|
|
|
|
// Correction: swap placements
|
|
await db.update(tournamentResults)
|
|
.set({ placement: 2 })
|
|
.where(and(eq(tournamentResults.tournamentId, seed.wimbledon.id), eq(tournamentResults.participantId, seed.canonicalIds.djokovic)));
|
|
await db.update(tournamentResults)
|
|
.set({ placement: 1 })
|
|
.where(and(eq(tournamentResults.tournamentId, seed.wimbledon.id), eq(tournamentResults.participantId, seed.canonicalIds.alcaraz)));
|
|
|
|
await syncTournamentResults(seed.wimbledon.id);
|
|
const updated = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));
|
|
const djok = updated.find((r) => r.seasonParticipantId === seed.seasonParticipantIds.djokovic)!;
|
|
const alc = updated.find((r) => r.seasonParticipantId === seed.seasonParticipantIds.alcaraz)!;
|
|
expect(djok.placement).toBe(2);
|
|
expect(djok.qualifyingPointsAwarded).toBe("50.00");
|
|
expect(alc.placement).toBe(1);
|
|
expect(alc.qualifyingPointsAwarded).toBe("100.00");
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests**
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/services/
|
|
git commit -m "test: sync idempotency and correction propagation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Sync — partial failure isolation
|
|
|
|
**Files:**
|
|
- Modify: `app/services/__tests__/sync-tournament-results.test.ts`
|
|
|
|
- [ ] **Step 1: Add failing test**
|
|
|
|
Test a scenario where one window's sync fails (e.g., qualifyingPointConfig missing for that season) — other windows must still succeed and the failure must be reported without being swallowed.
|
|
|
|
```typescript
|
|
it("one window failure does not block other windows; failure is reported", async () => {
|
|
const seed = await seedTennisWindowWithWimbledon();
|
|
|
|
// Second window WITHOUT qualifyingPointConfig — processQualifyingEvent should error.
|
|
const [ss2] = await db.insert(sportsSeasons).values({
|
|
sportId: seed.sportId, year: 2026, scoringPattern: "qualifying_points", totalMajors: 4,
|
|
}).returning();
|
|
const [ev2] = await db.insert(scoringEvents).values({
|
|
sportsSeasonId: ss2.id, name: "Wimbledon", eventType: "major_tournament",
|
|
isQualifyingEvent: true, tournamentId: seed.wimbledon.id, eventDate: "2026-07-12",
|
|
}).returning();
|
|
await db.insert(seasonParticipants).values({
|
|
sportsSeasonId: ss2.id, name: "Novak Djokovic", participantId: seed.canonicalIds.djokovic,
|
|
});
|
|
// Note: no qualifyingPointConfig rows for ss2 — this should cause processQualifyingEvent to error.
|
|
|
|
await db.insert(tournamentResults).values({
|
|
tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.djokovic, placement: 1,
|
|
});
|
|
|
|
const report = await syncTournamentResults(seed.wimbledon.id);
|
|
expect(report.windowsSynced).toBe(1);
|
|
expect(report.windowsFailed).toBe(1);
|
|
expect(report.failures).toHaveLength(1);
|
|
expect(report.failures[0].sportsSeasonId).toBe(ss2.id);
|
|
|
|
// First window still processed
|
|
const w1Results = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));
|
|
expect(w1Results.some((r) => r.qualifyingPointsAwarded === "100.00")).toBe(true);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test**
|
|
|
|
Expected: PASS. Per-window transactions in the service already isolate failures; verify behavior matches.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/services/
|
|
git commit -m "test: sync partial failure isolation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Switch canonical surface-Elo reads in tennis simulator
|
|
|
|
**Files:**
|
|
- Modify: `app/models/surface-elo.ts`
|
|
- Modify: `app/services/simulations/tennis-simulator.ts`
|
|
- Modify: `app/services/simulations/__tests__/tennis-simulator.test.ts` (update)
|
|
|
|
- [ ] **Step 1: Update `getSurfaceEloMap` to read from canonical**
|
|
|
|
In `app/models/surface-elo.ts`, change the query. Before:
|
|
|
|
```typescript
|
|
// reads from season_participant_surface_elos keyed by sportsSeasonId
|
|
```
|
|
|
|
After:
|
|
|
|
```typescript
|
|
// app/models/surface-elo.ts
|
|
import { db } from "~/db/context";
|
|
import { seasonParticipants, participants, participantSurfaceElos } from "database/schema";
|
|
import { eq } from "drizzle-orm";
|
|
|
|
export interface SurfaceEloEntry {
|
|
seasonParticipantId: string;
|
|
canonicalParticipantId: string;
|
|
eloHard: number | null;
|
|
eloClay: number | null;
|
|
eloGrass: number | null;
|
|
worldRanking: number | null;
|
|
}
|
|
|
|
export async function getSurfaceEloMap(sportsSeasonId: string): Promise<Map<string, SurfaceEloEntry>> {
|
|
const rows = await db
|
|
.select({
|
|
spId: seasonParticipants.id,
|
|
canonId: participants.id,
|
|
eloHard: participantSurfaceElos.eloHard,
|
|
eloClay: participantSurfaceElos.eloClay,
|
|
eloGrass: participantSurfaceElos.eloGrass,
|
|
worldRanking: participantSurfaceElos.worldRanking,
|
|
})
|
|
.from(seasonParticipants)
|
|
.innerJoin(participants, eq(seasonParticipants.participantId, participants.id))
|
|
.leftJoin(participantSurfaceElos, eq(participantSurfaceElos.participantId, participants.id))
|
|
.where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId));
|
|
|
|
const map = new Map<string, SurfaceEloEntry>();
|
|
for (const r of rows) {
|
|
map.set(r.spId, {
|
|
seasonParticipantId: r.spId,
|
|
canonicalParticipantId: r.canonId,
|
|
eloHard: r.eloHard,
|
|
eloClay: r.eloClay,
|
|
eloGrass: r.eloGrass,
|
|
worldRanking: r.worldRanking,
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update tennis-simulator to consume the new return shape (if changed)**
|
|
|
|
If `SurfaceEloEntry` shape matches what the simulator expects, no change. Otherwise rewrite the adapter.
|
|
|
|
- [ ] **Step 3: Run existing tennis simulator tests**
|
|
|
|
Run: `npm run test:run -- app/services/simulations/__tests__/tennis-simulator`
|
|
Expected: PASS against the canonical data. If a fixture is keyed by `participantId` in a way that breaks with the new canonical lookup, update the fixture.
|
|
|
|
- [ ] **Step 4: Add a regression test against the pre-Phase-1 baseline fixture**
|
|
|
|
```typescript
|
|
// app/services/simulations/__tests__/tennis-simulator.baseline.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "fs";
|
|
import { join } from "path";
|
|
import { runTennisSimulator } from "../tennis-simulator";
|
|
// Assumes a test helper that seeds canonical + season tables from baseline fixtures
|
|
import { seedFromBaseline } from "~/test/fixtures/seed-from-baseline";
|
|
|
|
describe("tennis simulator baseline regression", () => {
|
|
it("produces the same Monte Carlo output distribution as pre-Phase-1", async () => {
|
|
const baseline = JSON.parse(readFileSync(join(__dirname, "../../../../test-fixtures/baselines/tennis-sim-output-pre-phase1.json"), "utf8"));
|
|
const ssId = await seedFromBaseline(baseline);
|
|
const got = await runTennisSimulator(ssId, { seed: baseline.seed, iterations: 10000 });
|
|
for (const p of baseline.expected.participants) {
|
|
const match = got.participants.find((x) => x.canonicalName === p.canonicalName)!;
|
|
expect(match.probFirst).toBeCloseTo(p.probFirst, 2);
|
|
expect(match.probTop3).toBeCloseTo(p.probTop3, 2);
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
**Note:** This test requires both a baseline fixture (to be captured per the spec, Task 0 in Phase 1a, extended with Monte Carlo output) and a `seedFromBaseline` helper. If those don't yet exist, add a preliminary task to create them. **Gate Phase 3 on these existing before Task 6 runs.**
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/models/surface-elo.ts app/services/simulations/
|
|
git commit -m "refactor(simulator): read surface Elo from canonical table"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Admin UI — canonical tournament list page
|
|
|
|
**Files:**
|
|
- Create: `app/routes/admin.tournaments._index.tsx`
|
|
- Modify: `app/routes.ts` (register the route)
|
|
|
|
- [ ] **Step 1: Register the route**
|
|
|
|
In `app/routes.ts`, add to the admin section:
|
|
|
|
```typescript
|
|
route("admin/tournaments", "routes/admin.tournaments._index.tsx"),
|
|
route("admin/tournaments/:id", "routes/admin.tournaments.$id.tsx"),
|
|
```
|
|
|
|
- [ ] **Step 2: Write the list loader and component**
|
|
|
|
```typescript
|
|
// app/routes/admin.tournaments._index.tsx
|
|
import { requireAdmin } from "~/lib/auth";
|
|
import { findAllTournamentsGroupedBySport } from "~/models/tournament";
|
|
import type { LoaderFunctionArgs } from "react-router";
|
|
import { Link, useLoaderData } from "react-router";
|
|
|
|
export async function loader({ request }: LoaderFunctionArgs) {
|
|
await requireAdmin(request);
|
|
const grouped = await findAllTournamentsGroupedBySport();
|
|
return { grouped };
|
|
}
|
|
|
|
export default function AdminTournamentsIndex() {
|
|
const { grouped } = useLoaderData<typeof loader>();
|
|
return (
|
|
<div className="p-6">
|
|
<h1 className="text-2xl font-bold mb-4">Tournaments</h1>
|
|
{grouped.map((g) => (
|
|
<section key={g.sport.id} className="mb-8">
|
|
<h2 className="text-xl font-semibold">{g.sport.name}</h2>
|
|
<ul>
|
|
{g.tournaments.map((t) => (
|
|
<li key={t.id}>
|
|
<Link to={`/admin/tournaments/${t.id}`} className="text-blue-600 hover:underline">
|
|
{t.name} {t.year}
|
|
</Link>
|
|
<span className="ml-2 text-gray-500">({t.status})</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add `findAllTournamentsGroupedBySport` to `app/models/tournament.ts`**
|
|
|
|
```typescript
|
|
export async function findAllTournamentsGroupedBySport() {
|
|
const rows = await db
|
|
.select({
|
|
tournament: tournaments,
|
|
sport: sports,
|
|
})
|
|
.from(tournaments)
|
|
.innerJoin(sports, eq(tournaments.sportId, sports.id))
|
|
.orderBy(sports.name, tournaments.year, tournaments.startsAt);
|
|
|
|
const grouped = new Map<string, { sport: typeof sports.$inferSelect; tournaments: typeof tournaments.$inferSelect[] }>();
|
|
for (const r of rows) {
|
|
if (!grouped.has(r.sport.id)) {
|
|
grouped.set(r.sport.id, { sport: r.sport, tournaments: [] });
|
|
}
|
|
grouped.get(r.sport.id)!.tournaments.push(r.tournament);
|
|
}
|
|
return Array.from(grouped.values());
|
|
}
|
|
```
|
|
|
|
Import `sports` at the top of the file.
|
|
|
|
- [ ] **Step 4: Test loader**
|
|
|
|
Create `app/routes/__tests__/admin.tournaments._index.test.tsx` with loader tests (admin returns list; non-admin 403).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/routes/admin.tournaments._index.tsx app/routes/__tests__/admin.tournaments._index.test.tsx app/models/tournament.ts app/routes.ts
|
|
git commit -m "feat(admin): canonical tournaments list route"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Admin UI — canonical tournament detail + paste-and-parse result entry
|
|
|
|
**Files:**
|
|
- Create: `app/routes/admin.tournaments.$id.tsx`
|
|
- Create: `app/routes/__tests__/admin.tournaments.$id.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the loader + action test**
|
|
|
|
```typescript
|
|
// app/routes/__tests__/admin.tournaments.$id.test.tsx
|
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { loader, action } from "../admin.tournaments.$id";
|
|
// Test helpers that mock requireAdmin and build a Request
|
|
|
|
describe("admin tournament detail", () => {
|
|
it("loader returns tournament + canonical participants + current results", async () => {
|
|
// Seed a tournament, participants, tournament_results.
|
|
// Call loader with admin session.
|
|
// Assert shape.
|
|
});
|
|
|
|
it("action 'batch-upsert-results' writes tournament_results and triggers sync", async () => {
|
|
// Seed a tournament + window linked via scoring_events.
|
|
// POST form with intent=batch-upsert-results and a list of parsed results.
|
|
// Assert tournament_results written AND event_results propagated.
|
|
});
|
|
|
|
it("non-admin returns 403", async () => {
|
|
// ...
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run failing test**
|
|
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Implement route**
|
|
|
|
```typescript
|
|
// app/routes/admin.tournaments.$id.tsx
|
|
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
|
|
import { Form, useLoaderData, useActionData } from "react-router";
|
|
import { requireAdmin } from "~/lib/auth";
|
|
import { getTournamentById } from "~/models/tournament";
|
|
import { getTournamentResults, upsertTournamentResult } from "~/models/tournament-result";
|
|
import { findParticipantsBySport, upsertParticipantBySportName } from "~/models/participant";
|
|
import { syncTournamentResults } from "~/services/sync-tournament-results";
|
|
import { parseResultsText } from "~/lib/parse-results-text";
|
|
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
|
import { updateTournamentStatus } from "~/models/tournament";
|
|
|
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
|
await requireAdmin(request);
|
|
const tournament = await getTournamentById(params.id!);
|
|
if (!tournament) throw new Response("Not Found", { status: 404 });
|
|
const [results, canonicalParticipants] = await Promise.all([
|
|
getTournamentResults(tournament.id),
|
|
findParticipantsBySport(tournament.sportId),
|
|
]);
|
|
return { tournament, results, canonicalParticipants };
|
|
}
|
|
|
|
export async function action({ request, params }: ActionFunctionArgs) {
|
|
await requireAdmin(request);
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
const tournament = await getTournamentById(params.id!);
|
|
if (!tournament) throw new Response("Not Found", { status: 404 });
|
|
|
|
if (intent === "batch-upsert-results") {
|
|
const raw = String(formData.get("results-json") ?? "[]");
|
|
const parsed: Array<{ placement: number; canonicalParticipantId?: string; newName?: string }> =
|
|
JSON.parse(raw);
|
|
|
|
for (const row of parsed) {
|
|
let participantId = row.canonicalParticipantId;
|
|
if (!participantId && row.newName) {
|
|
const created = await upsertParticipantBySportName(tournament.sportId, row.newName);
|
|
participantId = created.id;
|
|
}
|
|
if (!participantId) continue;
|
|
|
|
await upsertTournamentResult({
|
|
tournamentId: tournament.id,
|
|
participantId,
|
|
placement: row.placement,
|
|
});
|
|
}
|
|
|
|
if (tournament.status !== "completed") {
|
|
await updateTournamentStatus(tournament.id, "completed");
|
|
}
|
|
|
|
const report = await syncTournamentResults(tournament.id);
|
|
return { success: true, syncReport: report };
|
|
}
|
|
|
|
throw new Response("Bad Request", { status: 400 });
|
|
}
|
|
|
|
export default function AdminTournamentDetail() {
|
|
const { tournament, results, canonicalParticipants } = useLoaderData<typeof loader>();
|
|
const actionData = useActionData<typeof action>();
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<h1 className="text-2xl font-bold">
|
|
{tournament.name} {tournament.year}
|
|
</h1>
|
|
<p className="text-gray-500">Status: {tournament.status}</p>
|
|
|
|
<section className="mt-6">
|
|
<h2 className="text-xl font-semibold mb-2">Current Results</h2>
|
|
<table>
|
|
<tbody>
|
|
{results.map((r) => (
|
|
<tr key={r.id}>
|
|
<td>{r.placement}</td>
|
|
<td>{canonicalParticipants.find((p) => p.id === r.participantId)?.name}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<section className="mt-6">
|
|
<h2 className="text-xl font-semibold mb-2">Enter Results</h2>
|
|
<BatchResultEntry
|
|
availableParticipants={canonicalParticipants.map((p) => ({ id: p.id, name: p.name }))}
|
|
intent="batch-upsert-results"
|
|
/>
|
|
</section>
|
|
|
|
{actionData?.syncReport && (
|
|
<aside className="mt-6 p-4 bg-green-50 rounded">
|
|
<p>Synced to {actionData.syncReport.windowsSynced} window(s).</p>
|
|
{actionData.syncReport.windowsFailed > 0 && (
|
|
<>
|
|
<p className="text-red-600">
|
|
Failed: {actionData.syncReport.windowsFailed}
|
|
</p>
|
|
<ul>
|
|
{actionData.syncReport.failures.map((f) => (
|
|
<li key={f.scoringEventId} className="text-sm">
|
|
{f.sportsSeasonId}: {f.error}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</aside>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Note:** `BatchResultEntry` component may need a prop `intent` added if the existing component hardcodes `"batch-add-results"` — check `app/components/BatchResultEntry.tsx` and adjust.
|
|
|
|
- [ ] **Step 4: Update `BatchResultEntry` if needed**
|
|
|
|
If `BatchResultEntry.tsx:175` hardcodes `formData.set("intent", "batch-add-results")`, change the component to accept an `intent` prop defaulting to `"batch-add-results"` so callers can override.
|
|
|
|
- [ ] **Step 5: Run tests**
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add app/routes/admin.tournaments.$id.tsx app/routes/__tests__/admin.tournaments.$id.test.tsx app/components/BatchResultEntry.tsx
|
|
git commit -m "feat(admin): canonical tournament result entry with sync fan-out"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Admin UI — surface-Elo canonical edit page
|
|
|
|
**Files:**
|
|
- Create: `app/routes/admin.participants.$id.surface-elo.tsx`
|
|
- Modify: `app/routes.ts`
|
|
|
|
- [ ] **Step 1: Register route**
|
|
|
|
In `app/routes.ts`:
|
|
```typescript
|
|
route("admin/participants/:id/surface-elo", "routes/admin.participants.$id.surface-elo.tsx"),
|
|
```
|
|
|
|
- [ ] **Step 2: Implement the page**
|
|
|
|
Simple form that reads canonical participant + current `participantSurfaceElos` row, allows editing `eloHard/eloClay/eloGrass/worldRanking`, and posts to an action that calls `upsertCanonicalSurfaceElo`. Non-admin → 403. Standard React Router 7 pattern.
|
|
|
|
- [ ] **Step 3: Leave the existing per-window surface-elo admin UI in place for now**
|
|
|
|
It still reads from the (now-renamed) `season_participant_surface_elos` table. Phase 4 removes it.
|
|
|
|
- [ ] **Step 4: Link from the canonical tournament detail page**
|
|
|
|
On the existing `admin.tournaments.$id.tsx`, add a "Manage Surface Elo" link next to each participant name that navigates to `/admin/participants/{participantId}/surface-elo`.
|
|
|
|
- [ ] **Step 5: Test + commit**
|
|
|
|
```bash
|
|
git add app/routes/admin.participants.$id.surface-elo.tsx app/routes.ts
|
|
git commit -m "feat(admin): canonical surface-elo edit page"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Create-window admin flow additions
|
|
|
|
**Goal:** When creating a new qualifying-points `sports_seasons`, admin picks from canonical tournaments + canonical participants instead of manually entering scoring-event rows and participant rows.
|
|
|
|
**Files:**
|
|
- Modify: existing admin route that creates `sports_seasons` (find via `grep -l "sportsSeasons.*insert" app/routes/admin.*.tsx`)
|
|
- Create: new UI components for tournament + participant multi-selects
|
|
- Modify: `app/models/season-sport.ts` or `app/models/sports-season.ts` to accept canonical IDs
|
|
|
|
- [ ] **Step 1: Find the existing create flow**
|
|
|
|
Run: `grep -l "insert(sportsSeasons)\|createSportsSeason" app/routes/ app/models/`
|
|
|
|
Identify the route(s) handling creation. Read them fully.
|
|
|
|
- [ ] **Step 2: Add canonical-aware create helper**
|
|
|
|
Add to `app/models/sports-season.ts`:
|
|
|
|
```typescript
|
|
export async function createQualifyingPointsSeason(params: {
|
|
sportId: string;
|
|
year: number;
|
|
tournamentIds: string[]; // canonical tournaments
|
|
canonicalParticipantIds: string[]; // canonical participants
|
|
scoringConfig: { pointsFor1st: string; /* ... */ };
|
|
}): Promise<typeof sportsSeasons.$inferSelect> {
|
|
return db.transaction(async (tx) => {
|
|
const [ss] = await tx.insert(sportsSeasons).values({
|
|
sportId: params.sportId,
|
|
year: params.year,
|
|
scoringPattern: "qualifying_points",
|
|
totalMajors: params.tournamentIds.length,
|
|
}).returning();
|
|
|
|
// Create scoring_events linked to tournaments
|
|
for (const tid of params.tournamentIds) {
|
|
const [t] = await tx.select().from(tournaments).where(eq(tournaments.id, tid));
|
|
if (!t) throw new Error(`tournament ${tid} not found`);
|
|
await tx.insert(scoringEvents).values({
|
|
sportsSeasonId: ss.id,
|
|
name: `${t.name}`,
|
|
eventDate: t.startsAt?.toISOString().slice(0, 10),
|
|
eventType: "major_tournament",
|
|
isQualifyingEvent: true,
|
|
tournamentId: t.id,
|
|
});
|
|
}
|
|
|
|
// Create season_participants linked to canonical
|
|
for (const pid of params.canonicalParticipantIds) {
|
|
const [p] = await tx.select().from(participants).where(eq(participants.id, pid));
|
|
if (!p) throw new Error(`participant ${pid} not found`);
|
|
await tx.insert(seasonParticipants).values({
|
|
sportsSeasonId: ss.id,
|
|
name: p.name,
|
|
participantId: p.id,
|
|
});
|
|
}
|
|
|
|
return ss;
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update the admin create route UI**
|
|
|
|
Add tournament multi-select (with inline "create new tournament" button) and participant multi-select (with inline "create new participant" button) to the existing form. On submit, call `createQualifyingPointsSeason`.
|
|
|
|
- [ ] **Step 4: E2E test**
|
|
|
|
```typescript
|
|
// cypress/e2e/admin-create-qualifying-window.cy.ts
|
|
describe("Admin creates a qualifying-points window", () => {
|
|
it("picks tournaments + participants from canonical", () => {
|
|
cy.loginAsAdmin();
|
|
cy.visit("/admin/sports-seasons/new");
|
|
// ... select sport = Tennis, pick 4 tournaments, pick N participants, submit.
|
|
cy.url().should("match", /\/admin\/sports-seasons\/[^/]+$/);
|
|
cy.findByText(/4 scoring events/i).should("exist");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add app/models/ app/routes/ cypress/e2e/
|
|
git commit -m "feat(admin): canonical tournaments + participants in create-window flow"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Convert old per-window result-entry route to wrapper
|
|
|
|
**Goal:** Keep the old route URL working (admin muscle memory) but have it write to canonical and call sync.
|
|
|
|
**Files:**
|
|
- Modify: the existing admin route that handles per-window qualifying result entry (find via `grep -l "batch-add-results" app/routes/`)
|
|
|
|
- [ ] **Step 1: Find the route**
|
|
|
|
Run: `grep -l "batch-add-results" app/routes/`
|
|
|
|
Read the current action handler.
|
|
|
|
- [ ] **Step 2: Replace the action body**
|
|
|
|
Before: writes directly to `event_results`.
|
|
After:
|
|
|
|
```typescript
|
|
// Pseudocode for the new action handler:
|
|
async function action({ request, params }: ActionFunctionArgs) {
|
|
await requireAdmin(request);
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "batch-add-results") {
|
|
// Load the scoring event
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event || !event.tournamentId) {
|
|
throw new Response("Event not linked to a canonical tournament", { status: 409 });
|
|
}
|
|
|
|
// Translate season_participant IDs → canonical participant IDs
|
|
const rows: Array<{ seasonParticipantId: string; placement: number }> = JSON.parse(String(formData.get("results-json")));
|
|
for (const row of rows) {
|
|
const [sp] = await db.select().from(seasonParticipants).where(eq(seasonParticipants.id, row.seasonParticipantId));
|
|
if (!sp || !sp.participantId) continue;
|
|
await upsertTournamentResult({
|
|
tournamentId: event.tournamentId,
|
|
participantId: sp.participantId,
|
|
placement: row.placement,
|
|
});
|
|
}
|
|
|
|
await syncTournamentResults(event.tournamentId);
|
|
return { success: true };
|
|
}
|
|
|
|
// ... other intents preserved
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Test the wrapper**
|
|
|
|
Existing E2E tests for per-window result entry should continue to pass. Run them.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add app/routes/
|
|
git commit -m "refactor(admin): per-window result entry writes canonical via sync"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Feature-flag the new admin routes + roll out
|
|
|
|
**Files:**
|
|
- Modify: `app/routes/admin.tournaments._index.tsx`, `app/routes/admin.tournaments.$id.tsx` — wrap in feature flag
|
|
|
|
- [ ] **Step 1: Decide flag mechanism**
|
|
|
|
If the project has an existing feature-flag mechanism (check `grep -rn "featureFlag\|FEATURE_" app/`), use it. If not, use an env var: `ENABLE_CANONICAL_ADMIN_UI=true`.
|
|
|
|
- [ ] **Step 2: Gate the new routes**
|
|
|
|
```typescript
|
|
// Inside loader:
|
|
if (!process.env.ENABLE_CANONICAL_ADMIN_UI) {
|
|
throw new Response("Not Found", { status: 404 });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Deploy to staging with flag=true; prod with flag=false; smoke-test**
|
|
|
|
Run the E2E flows in staging. Verify old flows still work in prod with flag=false.
|
|
|
|
- [ ] **Step 4: Choose a no-draft window and flip the prod flag**
|
|
|
|
Coordinate with active leagues. Flip flag, verify admin can reach `/admin/tournaments`.
|
|
|
|
- [ ] **Step 5: Run full Cypress suite post-flip**
|
|
|
|
Run: `npm run test:e2e:headless`
|
|
Expected: Green.
|
|
|
|
- [ ] **Step 6: Verify baseline regression**
|
|
|
|
Re-run `scripts/capture-baseline.ts` on prod; diff against pre-Phase-1 baseline:
|
|
|
|
```bash
|
|
diff test-fixtures/baselines/qp-totals-pre-phase1.json /tmp/post-phase3/qp-totals-*.json
|
|
diff test-fixtures/baselines/event-results-pre-phase1.json /tmp/post-phase3/event-results-*.json
|
|
```
|
|
|
|
Expected: Empty (QP math did not drift).
|
|
|
|
- [ ] **Step 7: Tag**
|
|
|
|
```bash
|
|
git tag phase-3-complete
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Checklist
|
|
|
|
- [ ] All new tests pass.
|
|
- [ ] `syncTournamentResults` is idempotent, isolates per-window failures, and respects per-window rosters.
|
|
- [ ] Tennis simulator output matches pre-Phase-1 baseline fixture.
|
|
- [ ] Check constraint prevents a qualifying `scoring_event` with null `tournament_id`.
|
|
- [ ] Admin can enter Wimbledon 2026 results once via `/admin/tournaments/<id>` and see both the original golf window and any new tennis window's QP standings update.
|
|
- [ ] Old per-window result entry route still works (wrapper path).
|
|
- [ ] Surface Elo edits via the canonical page affect all windows.
|
|
- [ ] Feature flag enabled in prod; tag `phase-3-complete` on main.
|
|
|
|
## Rollback
|
|
|
|
If the new admin UI causes a problem in prod, set `ENABLE_CANONICAL_ADMIN_UI=false` — this hides the canonical UI. Old wrappers still run, but the old wrappers write to canonical too, so rolling back only the UI doesn't rollback data writes.
|
|
|
|
If the sync service is broken (wrong QP math, wrong fan-out), revert the wrapper change first (make the old route write directly to event_results again), then investigate sync. Phase 4 must not begin until the cause is fixed.
|
|
|
|
Phase 4 begins only after `phase-3-complete` has been green in production for 7 days with active draft activity.
|