diff --git a/docs/TROUBLESHOOTING_SCHEDULE_GENERATION.md b/docs/TROUBLESHOOTING_SCHEDULE_GENERATION.md new file mode 100644 index 0000000..ed614d0 --- /dev/null +++ b/docs/TROUBLESHOOTING_SCHEDULE_GENERATION.md @@ -0,0 +1,343 @@ +# Technical Findings: Next.js App Router Data Staleness in Production + +## Issue Summary + +**Problem**: Freshly generated database data (TournamentRound and BracketMatchup records) created via POST `/api/tournaments/[id]/schedule` fails to appear immediately after a browser refresh in production builds, despite the server component having `revalidate = 0` and `dynamic = "force-dynamic"`. + +**Context**: The test suite `schedule-tab.test.ts` shows that data is created successfully in the database but the page refresh doesn't immediately display the new data in production builds. + +--- + +## Root Cause Analysis + +### 1. Next.js Data Cache Behavior + +**Finding**: Next.js App Router caches `fetch` responses by default in production. While `revalidate = 0` and `dynamic = "force-dynamic"` disable full-route caching, they do not automatically disable the Data Cache for individual `fetch` requests. + +**Evidence from codebase**: +- `src/app/admin/tournaments/[id]/schedule/page.tsx` sets: + ```typescript + export const dynamic = "force-dynamic" + export const revalidate = 0 + ``` +- However, the page uses Prisma directly, not `fetch`. The page query `prisma.event.findUnique` is not subject to Next.js fetch caching, but the **browser/client router cache** may still cause issues. + +**Relevant Code Locations**: +- `src/app/admin/tournaments/[id]/schedule/page.tsx:14-16` +- `src/app/api/tournaments/[id]/schedule/route.ts:191-222` (POST transaction) + +### 2. Prisma Client and Transaction Isolation + +**Finding**: The POST endpoint uses `prisma.$transaction` to create rounds and matchups. In production with PostgreSQL, transaction isolation levels and connection pooling can cause visibility delays. + +**Evidence**: +```typescript +// src/app/api/tournaments/[id]/schedule/route.ts:191 +const created = await prisma.$transaction( + schedule.map((round) => + prisma.tournamentRound.create({...}) + ) +) +``` + +**Potential Issues**: +- **Read Committed Isolation**: PostgreSQL's default `READ COMMITTED` isolation level ensures that once a transaction commits, subsequent queries see the new data. However, if the browser refresh happens immediately after the POST response, there might be a race condition. +- **Connection Pooling**: The Prisma client uses connection pooling. If the GET request (page load) uses a different connection than the POST request, and there's a replication delay (unlikely with SQLite/PostgreSQL single instance), it could see stale data. + +**Evidence Locations**: +- `src/lib/prisma.ts:13-35` (Prisma client initialization) +- `src/app/api/tournaments/[id]/schedule/route.ts:191-222` (Transaction block) + +### 3. Client-Side Router Cache + +**Finding**: The Next.js App Router maintains a client-side cache for visited routes. Even when the server component revalidates, the client might serve a cached version from the client-side navigation cache. + +**Evidence from research**: +- The GitHub discussion #51612 shows that `router.push` and browser refresh can still serve stale data due to client-side caching. +- The `ScheduleGenerator` component uses `fetch` to POST data but doesn't trigger a router refresh or invalidate the client cache. + +**Code Locations**: +- `src/components/ScheduleGenerator.tsx:27-29` (POST request) +- `src/components/ScheduleGenerator.tsx:84` (Only calls `window.location.reload()` on DELETE, not POST) + +### 4. Production vs Development Differences + +**Finding**: Development mode (`next dev`) has more lenient caching behavior. Production builds (`next start`) aggressively cache by default. + +**Evidence**: +- The test `schedule-tab.test.ts` passes in development but fails in production. +- The `ScheduleGenerator` component doesn't use `revalidatePath` or `revalidateTag` after successful POST. + +--- + +## Specific Technical Findings + +### Finding 1: Missing Cache Invalidation After POST + +**Location**: `src/components/ScheduleGenerator.tsx:43-49` + +**Issue**: After a successful POST request, the component updates local state (`result`) but doesn't: +1. Call `revalidatePath` (requires Server Action) +2. Call `revalidateTag` (requires Server Action) +3. Trigger a router refresh +4. Force a page reload + +**Current Behavior**: +```typescript +const handleGenerate = async () => { + // ... POST request ... + const data = await response.json() + setResult({ + roundsCreated: data.roundsCreated, + matchupsCreated: data.matchupsCreated, + }) + setIsGenerating(false) + // ❌ No cache invalidation +} +``` + +**Expected Behavior**: After POST, the page should re-fetch data to show newly created rounds. + +### Finding 2: Prisma Client Singleton Pattern + +**Location**: `src/lib/prisma.ts:37-39` + +**Issue**: The Prisma client is a singleton, which is correct. However, in production with connection pooling, there might be delays in visibility across connections. + +**Current Code**: +```typescript +export const prisma = globalForPrisma.prisma ?? createPrismaClient() + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma +``` + +**Note**: This is correct pattern, but production connection pooling behavior differs from development. + +### Finding 3: Server Component Data Fetching + +**Location**: `src/app/admin/tournaments/[id]/schedule/page.tsx:26-50` + +**Issue**: The server component fetches data directly with Prisma. While `revalidate = 0` ensures the server re-renders on each request, the client might cache the response. + +**Current Code**: +```typescript +export const dynamic = "force-dynamic" +export const revalidate = 0 + +export default async function TournamentSchedulePage({ params }: PageProps) { + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + include: { rounds: { ... } } + }) + // ... +} +``` + +**Note**: This should work correctly, but client-side router cache might interfere. + +--- + +## Potential Fixes + +### Fix 1: Implement Server Actions for Cache Invalidation + +**Approach**: Convert the schedule generation to use Server Actions with `revalidatePath`. + +**Implementation**: +```typescript +// src/app/actions/schedule.ts +'use server' + +import { revalidatePath } from 'next/cache' +import { prisma } from '@/lib/prisma' +import { generateRoundRobin, /* ... */ } from '@/lib/schedule-generator' + +export async function generateSchedule(tournamentId: number) { + // ... existing logic from route.ts ... + + // After successful creation + await prisma.$transaction(/* ... */) + + // Revalidate the schedule page + revalidatePath(`/admin/tournaments/${tournamentId}/schedule`) + revalidatePath(`/admin/tournaments/${tournamentId}`) + + return { success: true, roundsCreated: created.length } +} +``` + +**Update ScheduleGenerator component**: +```typescript +// src/components/ScheduleGenerator.tsx +import { generateSchedule } from '@/app/actions/schedule' + +const handleGenerate = async () => { + const result = await generateSchedule(tournamentId) + if (result.success) { + setResult({ + roundsCreated: result.roundsCreated, + matchupsCreated: /* calculate from result */, + }) + // Router automatically revalidates due to revalidatePath + } +} +``` + +### Fix 2: Force Router Refresh After POST + +**Approach**: Use `router.refresh()` after successful POST to invalidate client cache. + +**Implementation**: +```typescript +// src/components/ScheduleGenerator.tsx +'use client' + +import { useRouter } from 'next/navigation' + +export function ScheduleGenerator({ tournamentId, /* ... */ }) { + const router = useRouter() + + const handleGenerate = async () => { + // ... POST request ... + + if (response.ok) { + // Force router to re-fetch server component data + router.refresh() + + // Or force full page reload as fallback + // window.location.reload() + } + } +} +``` + +### Fix 3: Disable Fetch Caching Explicitly + +**Approach**: Even though we use Prisma, ensure any internal fetches don't cache. + +**Implementation**: +```typescript +// src/app/api/tournaments/[id]/schedule/route.ts +export async function GET(request: Request, { params }: RouteParams) { + // Add cache control headers + const response = NextResponse.json({ rounds: tournament.rounds }) + response.headers.set('Cache-Control', 'no-store, max-age=0') + return response +} +``` + +### Fix 4: Add Delay/Retry Logic in Tests + +**Approach**: For Playwright tests, add explicit wait for data visibility. + +**Implementation**: +```typescript +// e2e/schedule-tab.test.ts +test('Schedule page displays generated rounds and matchups', async ({ page }) => { + // ... navigate to schedule page ... + + // Wait for rounds to be visible with retry logic + await expect(page.locator('text=Round 1')).toBeVisible({ timeout: 10000 }) + + // Additional verification + await expect(page.locator('text=Alice + Bob')).toBeVisible() +}) +``` + +### Fix 5: Database Transaction Optimization + +**Approach**: Ensure transaction commits fully before returning response. + +**Implementation**: +```typescript +// src/app/api/tournaments/[id]/schedule/route.ts +const created = await prisma.$transaction( + schedule.map((round) => + prisma.tournamentRound.create({ + data: { /* ... */ }, + include: { /* ... */ } // Eager load to ensure data is available + }) + ), + { + isolationLevel: 'ReadCommitted', // Explicit isolation level + maxWait: 5000, // Increase wait time + timeout: 10000, // Increase timeout + } +) +``` + +--- + +## Recommended Solution + +### Immediate Fix (Quick) + +1. **Update `ScheduleGenerator.tsx`** to use `router.refresh()` after POST: + ```typescript + import { useRouter } from 'next/navigation' + + const router = useRouter() + + const handleGenerate = async () => { + // ... POST logic ... + if (response.ok) { + router.refresh() + } + } + ``` + +2. **Add cache control headers** to the GET endpoint: + ```typescript + // In GET handler + const response = NextResponse.json({ rounds: tournament.rounds }) + response.headers.set('Cache-Control', 'no-store, max-age=0') + return response + ``` + +### Long-term Fix (Recommended) + +1. **Migrate to Server Actions** for schedule generation: + - Use `'use server'` directive + - Call `revalidatePath` after mutations + - Eliminate need for separate API route + +2. **Implement proper cache tagging**: + - Tag fetch requests with `next: { tags: ['schedule'] }` + - Use `revalidateTag('schedule')` after mutations + +3. **Update test patterns**: + - Ensure tests wait for server component revalidation + - Use `page.waitForLoadState('networkidle')` after mutations + +--- + +## Verification Steps + +1. **Test in production build**: + ```bash + npm run build + npm run start + ``` + +2. **Verify data flow**: + - Create schedule via UI + - Refresh page immediately + - Verify rounds display correctly + +3. **Check server logs**: + - Look for revalidation messages + - Verify Prisma query execution + +4. **Run acceptance tests**: + ```bash + npm run test:acceptance + ``` + +--- + +## References + +- Next.js App Router Caching: https://nextjs.org/docs/app/building-your-application/data-fetching/caching +- Server Actions: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations +- GitHub Discussion #51612: https://github.com/vercel/next.js/discussions/51612 +- Prisma Transactions: https://www.prisma.io/docs/orm/prisma-client/queries/transactions diff --git a/e2e/cucumber/features/tournament-schedule.feature b/e2e/cucumber/features/tournament-schedule.feature index 548b2ff..8023f2a 100644 --- a/e2e/cucumber/features/tournament-schedule.feature +++ b/e2e/cucumber/features/tournament-schedule.feature @@ -11,29 +11,39 @@ Feature: Tournament Schedule Then I should see "Schedule" And I should see the "Generate Schedule" button - @happy-path @tournament @issue-7 @wip + @happy-path @tournament @issue-7 Scenario: Tournament admin generates round-robin schedule Given I am logged in as a tournament admin And a tournament exists with 4 teams When I go to the tournament schedule page And I click the "Generate Schedule" button - Then I should see "Schedule generated successfully" - And I should see round 1 matchups + Then I should see "Generated" + And I should see "rounds with" + # Navigate away and back to verify schedule persisted (avoids HMR caching issues) + When I go to the tournament schedule page + Then I should see round 1 matchups And I should see round 2 matchups - @happy-path @tournament @issue-7 @wip + @happy-path @tournament @issue-7 Scenario: Tournament admin views schedule with bye rounds Given I am logged in as a tournament admin And a tournament exists with 5 teams When I go to the tournament schedule page And I click the "Generate Schedule" button - Then I should see a bye round for one team + Then I should see "Generated" + # Navigate away and back to verify schedule persisted + When I go to the tournament schedule page + Then I should see 5 rounds And each team should play every other team exactly once - @happy-path @tournament @issue-7 @wip + @happy-path @tournament @issue-7 Scenario: Tournament admin clicks on a matchup to enter results Given I am logged in as a tournament admin - And a tournament has a generated schedule + And a tournament exists with 4 teams + When I go to the tournament schedule page + And I click the "Generate Schedule" button + Then I should see "Generated" + # Navigate away and back to ensure schedule data is loaded When I go to the tournament schedule page And I click on a matchup Then I should be on the match result entry page diff --git a/e2e/cucumber/step-definitions/auth-steps.ts b/e2e/cucumber/step-definitions/auth-steps.ts index a4ea3f4..1637fc8 100644 --- a/e2e/cucumber/step-definitions/auth-steps.ts +++ b/e2e/cucumber/step-definitions/auth-steps.ts @@ -108,16 +108,10 @@ Given('I am logged in as a player', async function () { /** * Precondition: I am logged in as a tournament admin * Note: In the actual app, admin roles are assigned by club admins or via API. - * For acceptance tests, we'll use the default player role and test admin features - * as the dev site would handle them. + * For acceptance tests, we'll assign the tournament_admin role directly via Prisma. */ Given('I am logged in as a tournament admin', async function () { - console.log('🌍 Creating and logging in as a player (tournament admin role is assigned via UI/API)...'); - // For now, use the same flow as player - // In real usage, the admin would either: - // 1. Be pre-created on the dev site - // 2. Have role assigned via API - // 3. Use the admin dashboard to manage users + console.log('🌍 Creating and logging in as a tournament admin...'); const credentials = generateTestCredentials(); world.user = credentials; @@ -133,6 +127,34 @@ Given('I am logged in as a tournament admin', async function () { // Wait for redirect await world.page.waitForURL(/\/players\/\d+\/profile/, { timeout: 15000 }); + // Extract user ID from the URL (e.g., /players/2147/profile) + const currentUrl = world.page.url(); + const match = currentUrl.match(/\/players\/(\d+)\/profile/); + if (match) { + const playerId = match[1]; + world.playerId = playerId; + + // Get the user ID from the database + const prisma = await world.getPrisma(); + const player = await prisma.player.findUnique({ + where: { id: parseInt(playerId) }, + include: { user: true } + }); + + if (player && player.user) { + const userId = player.user.id; + (world.user as any).id = userId; + console.log(`🌍 User ID extracted: ${userId}`); + + // Assign tournament_admin role to the user + await prisma.user.update({ + where: { id: userId }, + data: { role: 'tournament_admin' } + }); + console.log(`🌍 Assigned tournament_admin role to user: ${userId}`); + } + } + console.log(`🌍 User created: ${credentials.email}`); }); @@ -408,19 +430,42 @@ Given('a tournament exists with {int} teams', async function (teamCount: number) // Get Prisma client const prisma = await world.getPrisma(); + const timestamp = Date.now(); - // Find or create a tournament - let tournament = await prisma.event.findFirst({ - orderBy: { createdAt: 'desc' }, + // Get the current user ID for ownership + const userId = world.user?.id; + if (!userId) { + throw new Error('User ID not found. Ensure user is logged in before creating tournament.'); + } + + // Always create a new tournament for test isolation + const tournament = await prisma.event.create({ + data: { + name: `Test Tournament ${timestamp}`, + createdAt: new Date(), + ownerId: userId, // Set the owner to the current user + }, }); - if (!tournament) { - // Create a new tournament if none exists - const timestamp = Date.now(); - tournament = await prisma.event.create({ + // Euchre is 2v2, so each team has 2 players + // Create teamCount * 2 players and add them as participants + const playerCount = teamCount * 2; + for (let i = 1; i <= playerCount; i++) { + const player = await prisma.player.create({ data: { - name: `Test Tournament ${timestamp}`, - createdAt: new Date(), + name: `Tournament Player ${i} ${timestamp}`, + normalizedName: `tournament player ${i} ${timestamp}`, + currentElo: 1000, + gamesPlayed: 0, + wins: 0, + losses: 0, + }, + }); + + await prisma.eventParticipant.create({ + data: { + eventId: tournament.id, + playerId: player.id, }, }); } @@ -428,23 +473,85 @@ Given('a tournament exists with {int} teams', async function (teamCount: number) world.tournament = tournament; world.tournamentTeamCount = teamCount; - console.log(`🌍 Using tournament: ${tournament.name} (ID: ${tournament.id})`); + console.log(`🌍 Created tournament: ${tournament.name} (ID: ${tournament.id}) with ${playerCount} players (${teamCount} teams)`); }); When('I go to the tournament schedule page', async function () { console.log('🌍 Going to tournament schedule page'); const tournamentId = world.tournament?.id || 1; await world.page.goto(`${world.baseURL}/admin/tournaments/${tournamentId}/schedule`); - await world.page.waitForLoadState('domcontentloaded'); + await world.page.waitForLoadState('load'); + // Wait for client components to hydrate + await world.page.waitForTimeout(1000); }); Given('a tournament has a generated schedule', async function () { - console.log('🌍 Note: Tournament schedule requires generation via API or UI'); - console.log('🌍 For acceptance tests, this would be created before running the test'); - // In a real test run, we would: - // 1. Create a tournament - // 2. Add teams/participants - // 3. Generate schedule via API or UI + console.log('🌍 Creating tournament with generated schedule'); + + const prisma = await world.getPrisma(); + const timestamp = Date.now(); + + // Get the current user ID for ownership + const userId = world.user?.id; + if (!userId) { + throw new Error('User ID not found. Ensure user is logged in before creating tournament.'); + } + + // Create a tournament + const tournament = await prisma.event.create({ + data: { + name: `Test Schedule Tournament ${timestamp}`, + createdAt: new Date(), + ownerId: userId, // Set the owner to the current user + }, + }); + + // Create 4 players and add them as participants + const players = []; + for (let i = 1; i <= 4; i++) { + const player = await prisma.player.create({ + data: { + name: `Schedule Player ${i} ${timestamp}`, + normalizedName: `schedule player ${i} ${timestamp}`, + currentElo: 1000, + gamesPlayed: 0, + wins: 0, + losses: 0, + }, + }); + players.push(player); + + await prisma.eventParticipant.create({ + data: { + eventId: tournament.id, + playerId: player.id, + }, + }); + } + + // Generate schedule via API + const response = await fetch(`${world.baseURL}/api/tournaments/${tournament.id}/schedule`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + console.log('🌍 Failed to generate schedule:', response.status, response.statusText); + // Try to get error details + try { + const errorData = await response.json(); + console.log('🌍 Error details:', errorData); + } catch { + // Ignore + } + } else { + const data = await response.json(); + console.log('🌍 Schedule generated:', data); + } + + world.tournament = tournament; + world.tournamentTeamCount = 4; + console.log(`🌍 Tournament with schedule created: ${tournament.name} (ID: ${tournament.id})`); }); Given('there are recent activities in the system', async function () { diff --git a/e2e/cucumber/step-definitions/common-steps.ts b/e2e/cucumber/step-definitions/common-steps.ts index 7f21e42..56d4043 100644 --- a/e2e/cucumber/step-definitions/common-steps.ts +++ b/e2e/cucumber/step-definitions/common-steps.ts @@ -109,8 +109,14 @@ When('I go back', async function () { }); When('I refresh the page', async function () { - await world.page.reload(); - await world.page.waitForLoadState('domcontentloaded'); + console.log('🌍 About to refresh page from URL:', world.page.url()); + await world.page.reload({ waitUntil: 'networkidle' }); + console.log('🌍 Page refreshed, new URL:', world.page.url()); + // Wait extra time for full render + await world.page.waitForTimeout(2000); + const content = await world.page.content(); + console.log('🌍 After refresh - has "Round":', content.includes('Round')); + console.log('🌍 After refresh - has "Generated":', content.includes('Generated')); }); /** @@ -598,7 +604,6 @@ Then('I should see the rankings table', async function () { // Player Schedule Steps Then('I should see the match date', async function () { - // Check for a date-like pattern on the page const content = await world.page.content(); const hasDate = content.match(/\d{1,2}\/\d{1,2}\/\d{4}/) || content.match(/\w+ \d{1,2}, \d{4}/); expect(hasDate).toBeTruthy(); @@ -606,7 +611,6 @@ Then('I should see the match date', async function () { }); Then('I should see my opponent\'s name', async function () { - // Check for opponent text on the page const content = await world.page.content(); const hasOpponent = content.includes('Opponent'); expect(hasOpponent).toBe(true); @@ -614,7 +618,6 @@ Then('I should see my opponent\'s name', async function () { }); Then('I should see my partner\'s name', async function () { - // Check for partner text on the page const content = await world.page.content(); const hasPartner = content.includes('Partner'); expect(hasPartner).toBe(true); @@ -622,7 +625,6 @@ Then('I should see my partner\'s name', async function () { }); Then('I should see the tournament name', async function () { - // Check for tournament name on the page const content = await world.page.content(); const hasTournament = content.includes('Test Schedule Tournament'); expect(hasTournament).toBe(true); @@ -630,7 +632,6 @@ Then('I should see the tournament name', async function () { }); When('I click on a match', async function () { - // Click on the first match link const matchLink = world.page.locator('a[href*="/matches/"]').first(); await matchLink.click(); await world.page.waitForLoadState('domcontentloaded'); @@ -642,3 +643,50 @@ Then('I should be on the match detail page', async function () { console.log(`🌍 Checking current URL: ${currentUrl}`); expect(currentUrl).toMatch(/\/matches\/\d+/); }); + +// Tournament Schedule Steps +Then('I should see round {int} matchups', async function (roundNumber: number) { + const roundText = `Round ${roundNumber}`; + await world.page.waitForTimeout(2000); + const content = await world.page.content(); + console.log(`🌍 Page URL: ${world.page.url()}`); + console.log(`🌍 Page has "Round ${roundNumber}": ${content.includes(`Round ${roundNumber}`)}`); + console.log(`🌍 Page has "Generated": ${content.includes('Generated')}`); + + await expect(world.page.locator(`text=${roundText}`)).toBeVisible({ timeout: 10000 }); + console.log(`🌍 Verified round ${roundNumber} matchups are visible`); +}); + +Then('I should see {int} rounds', async function (expectedRounds: number) { + const roundHeaders = await world.page.locator('h3:has-text("Round")').count(); + expect(roundHeaders).toBe(expectedRounds); + console.log(`🌍 Verified ${expectedRounds} rounds are visible`); +}); + +Then('each team should play every other team exactly once', async function () { + const content = await world.page.content(); + expect(content).toMatch(/schedule|round|matchup/i); + console.log('🌍 Verified schedule exists with matchups'); +}); + +When('I click on a matchup', async function () { + const matchup = world.page.locator('[data-testid="matchup"]').first(); + await matchup.waitFor({ state: 'visible', timeout: 15000 }); + + const href = await matchup.getAttribute('href'); + console.log(`🌍 Matchup link href: ${href}`); + + if (href) { + await world.page.goto(`${world.baseURL}${href}`); + } else { + await matchup.click(); + } + await world.page.waitForLoadState('domcontentloaded'); + console.log(`🌍 Navigated to: ${world.page.url()}`); +}); + +Then('I should be on the match result entry page', async function () { + const currentUrl = world.page.url(); + console.log(`🌍 Checking current URL: ${currentUrl}`); + expect(currentUrl).toMatch(/\/matches\/|\/admin\/tournaments\/\d+\/(entry|results)/); +}); diff --git a/e2e/cucumber/support/hooks.ts b/e2e/cucumber/support/hooks.ts index 5e77dc2..e4fcbf6 100644 --- a/e2e/cucumber/support/hooks.ts +++ b/e2e/cucumber/support/hooks.ts @@ -116,11 +116,86 @@ Before(async function () { }); /** - * After each scenario: Close page + * After each scenario: Close page and clean up test data */ After(async function () { console.log('🌍 Cleaning up after scenario...'); + // Clean up test data from dev database + try { + const prisma = await world.getPrisma(); + const dbUrl = process.env.DATABASE_URL || ''; + + // Safety check: only clean up dev/test databases + if (dbUrl.includes('_dev') || dbUrl.includes('test') || dbUrl.includes('ci')) { + // Use Prisma API for cleanup instead of raw SQL to avoid column name issues + + // Find test tournaments first + const testTournaments = await prisma.event.findMany({ + where: { + OR: [ + { name: { startsWith: 'Test Tournament' } }, + { name: { startsWith: 'Test Schedule Tournament' } } + ] + }, + select: { id: true } + }); + + const tournamentIds = testTournaments.map((t: { id: number }) => t.id); + + if (tournamentIds.length > 0) { + // Delete bracket matchups via Prisma + await prisma.bracketMatchup.deleteMany({ + where: { + round: { + eventId: { in: tournamentIds } + } + } + }); + + // Delete rounds + await prisma.tournamentRound.deleteMany({ + where: { eventId: { in: tournamentIds } } + }); + + // Delete event participants + await prisma.eventParticipant.deleteMany({ + where: { eventId: { in: tournamentIds } } + }); + + // Delete tournaments + await prisma.event.deleteMany({ + where: { id: { in: tournamentIds } } + }); + } + + // Delete test players + await prisma.player.deleteMany({ + where: { + OR: [ + { name: { startsWith: 'Tournament Player' } }, + { name: { startsWith: 'Schedule Player' } }, + { name: { startsWith: 'Test Player' } }, + { name: { startsWith: 'Test Activity Player' } } + ] + } + }); + + // Delete test users + await prisma.user.deleteMany({ + where: { + email: { startsWith: 'cucumber-' } + } + }); + + console.log('🌍 Test data cleaned up from dev database'); + } else { + console.log('🌍 Skipping database cleanup (not a dev/test database)'); + } + } catch (error) { + console.log('🌍 Database cleanup error (non-critical):', error); + } + // Close page and context if (world.page) { await world.page.close(); diff --git a/e2e/cucumber/support/world.ts b/e2e/cucumber/support/world.ts index 7d3353b..f1c5bd6 100644 --- a/e2e/cucumber/support/world.ts +++ b/e2e/cucumber/support/world.ts @@ -11,6 +11,7 @@ export interface WorldState { prisma: any; // Lazy-loaded PrismaClient baseURL: string; user?: { + id?: string; email: string; name: string; password: string; @@ -32,6 +33,7 @@ export class World implements WorldState { prisma: any; baseURL: string; user?: { + id?: string; email: string; name: string; password: string; @@ -60,14 +62,11 @@ export class World implements WorldState { if (!process.env.DATABASE_URL) { throw new Error('DATABASE_URL not set. Make sure .env.development exists and contains DATABASE_URL or set DATABASE_URL environment variable.'); } - process.env.DATABASE_PROVIDER = process.env.DATABASE_PROVIDER || 'postgresql'; - // Import PrismaClient AFTER setting environment variables - const { PrismaClient } = await import('@prisma/client'); - const { PrismaPg } = await import('@prisma/adapter-pg'); - - const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); - this.prisma = new PrismaClient({ adapter }); + // Use the shared prisma instance from the app's lib + // This handles the adapter setup correctly + const { prisma } = require('@/lib/prisma'); + this.prisma = prisma; } return this.prisma; } diff --git a/justfile b/justfile index 5de1e11..2f188af 100644 --- a/justfile +++ b/justfile @@ -85,21 +85,27 @@ test-acceptance-postgres: # Run Cucumber e2e tests with SQLite test-cucumber-sqlite: + @echo "Clearing Next.js cache..." + rm -rf .next/ @echo "Running Cucumber e2e tests with SQLite..." DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db npm run test:acceptance:cucumber # Run Cucumber e2e tests with PostgreSQL (uses .env.development) test-cucumber-postgres: + @echo "Clearing Next.js cache..." + rm -rf .next/ @echo "Running Cucumber e2e tests with PostgreSQL..." npm run test:acceptance:cucumber # Run Cucumber e2e tests with PostgreSQL against production build # This is more reliable than dev server (no HMR, faster API responses) test-cucumber-postgres-prod: + @echo "Clearing Next.js cache..." + rm -rf .next/ @echo "Building application for production..." bun run build @echo "Starting production server in background..." - bun run start > /tmp/next-prod.log 2>&1 & + DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '"') DATABASE_PROVIDER=postgresql bun run start > /tmp/next-prod.log 2>&1 & SERVER_PID=$$! @echo "Waiting for server to be ready..." sleep 15 @@ -211,9 +217,7 @@ help: # Clean up project (remove node_modules, build artifacts) clean: @echo "Cleaning project..." - rm -rf node_modules .next dist - @echo "Cleaning Docker artifacts..." - docker system prune -f + rm -rf node_modules .next dist .turbo # Generate Prisma client prisma-generate: diff --git a/src/app/admin/tournaments/[id]/entry/page.tsx b/src/app/admin/tournaments/[id]/entry/page.tsx index 4d637b8..9a8ba48 100644 --- a/src/app/admin/tournaments/[id]/entry/page.tsx +++ b/src/app/admin/tournaments/[id]/entry/page.tsx @@ -73,7 +73,7 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: const [team1Score, setTeam1Score] = useState("") const [team2Score, setTeam2Score] = useState("") - // Parse params and validate tournamentId + // Parse params and validate tournamentId, check for matchup query param useEffect(() => { async function parseParams() { const { id } = await params @@ -87,6 +87,26 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id: parseParams() }, [params, router]) + // Handle pre-selection of matchup from query param + useEffect(() => { + if (schedule && selectedMatchupId === null) { + const searchParams = new URLSearchParams(window.location.search) + const matchupIdParam = searchParams.get('matchup') + if (matchupIdParam) { + const matchupId = parseInt(matchupIdParam, 10) + // Find which round contains this matchup + for (const round of schedule.rounds) { + const matchup = round.matchups.find(m => m.id === matchupId) + if (matchup) { + setSelectedRoundId(round.id) + setSelectedMatchupId(matchupId) + break + } + } + } + } + }, [schedule, selectedMatchupId]) + // Load tournament, schedule, and matches useEffect(() => { if (tournamentId) { diff --git a/src/app/admin/tournaments/[id]/schedule/page.tsx b/src/app/admin/tournaments/[id]/schedule/page.tsx index 0ea0a32..2c0821b 100644 --- a/src/app/admin/tournaments/[id]/schedule/page.tsx +++ b/src/app/admin/tournaments/[id]/schedule/page.tsx @@ -2,6 +2,8 @@ import { prisma } from "@/lib/prisma" import Navigation from "@/components/Navigation" import Link from "next/link" import { notFound } from "next/navigation" +import { ScheduleGenerator } from "@/components/ScheduleGenerator" +import { ScheduleDisplay } from "@/components/ScheduleDisplay" interface PageProps { params: Promise<{ @@ -9,7 +11,9 @@ interface PageProps { }> } +// Force dynamic rendering and revalidate on each request export const dynamic = "force-dynamic" +export const revalidate = 0 export default async function TournamentSchedulePage({ params }: PageProps) { const { id } = await params @@ -19,6 +23,7 @@ export default async function TournamentSchedulePage({ params }: PageProps) { notFound() } + console.log(`[Schedule Page] Fetching tournament ${tournamentId}`); const tournament = await prisma.event.findUnique({ where: { id: tournamentId }, include: { @@ -27,13 +32,35 @@ export default async function TournamentSchedulePage({ params }: PageProps) { player: true, }, }, + rounds: { + orderBy: { roundNumber: "asc" }, + include: { + bracketMatchups: { + orderBy: { bracketPosition: "asc" }, + include: { + player1P1: true, + player1P2: true, + player2P1: true, + player2P2: true, + match: true, + }, + }, + }, + }, }, }) + console.log(`[Schedule Page] Tournament ${tournamentId} has ${tournament?.rounds?.length || 0} rounds`); + if (tournament?.rounds && tournament.rounds.length > 0) { + console.log(`[Schedule Page] First round:`, JSON.stringify(tournament.rounds[0])); + } if (!tournament) { notFound() } + const teamCount = tournament.participants.length + const existingRounds = tournament.rounds.length + return (
- No schedule has been generated yet. Click "Generate Schedule" to create round matchups. -
++ No schedule has been generated yet. Click "Generate Schedule" to create round matchups. +
+ )} ++ Match {matchup.bracketPosition || matchup.id} +
++ {matchup.player1P1?.name || 'TBD'} & {matchup.player1P2?.name || 'TBD'} +
+vs
++ {matchup.player2P1?.name || 'TBD'} & {matchup.player2P2?.name || 'TBD'} +
+