fix: resolve schedule data staleness in production builds
- Replace window.location.reload() with router.refresh() in ScheduleGenerator, MatchEditor, RecalculateEloButton for proper Next.js cache invalidation - Add revalidatePath() call after schedule generation in POST handler - Add ownerId to tournament creation in cucumber tests for proper permission checks - Assign tournament_admin role via Prisma after user creation in cucumber tests - Fix TypeScript type annotations in hooks.ts (tournament id map) - Update page reload to use networkidle in common-steps.ts - Clear .next/ cache before cucumber tests in justfile - Add .turbo to clean target - Add comprehensive debug logging to schedule API route - Document findings in docs/TROUBLESHOOTING_SCHEDULE_GENERATION.md
This commit is contained in:
@@ -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
|
||||||
@@ -108,16 +108,10 @@ Given('I am logged in as a player', async function () {
|
|||||||
/**
|
/**
|
||||||
* Precondition: I am logged in as a tournament admin
|
* Precondition: I am logged in as a tournament admin
|
||||||
* Note: In the actual app, admin roles are assigned by club admins or via API.
|
* 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
|
* For acceptance tests, we'll assign the tournament_admin role directly via Prisma.
|
||||||
* as the dev site would handle them.
|
|
||||||
*/
|
*/
|
||||||
Given('I am logged in as a tournament admin', async function () {
|
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)...');
|
console.log('🌍 Creating and logging in as a tournament admin...');
|
||||||
// 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
|
|
||||||
|
|
||||||
const credentials = generateTestCredentials();
|
const credentials = generateTestCredentials();
|
||||||
world.user = credentials;
|
world.user = credentials;
|
||||||
@@ -133,6 +127,34 @@ Given('I am logged in as a tournament admin', async function () {
|
|||||||
// Wait for redirect
|
// Wait for redirect
|
||||||
await world.page.waitForURL(/\/players\/\d+\/profile/, { timeout: 15000 });
|
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}`);
|
console.log(`🌍 User created: ${credentials.email}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -359,11 +381,18 @@ Given('a tournament exists with {int} teams', async function (teamCount: number)
|
|||||||
const prisma = await world.getPrisma();
|
const prisma = await world.getPrisma();
|
||||||
const timestamp = Date.now();
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
// Always create a new tournament for test isolation
|
// Always create a new tournament for test isolation
|
||||||
const tournament = await prisma.event.create({
|
const tournament = await prisma.event.create({
|
||||||
data: {
|
data: {
|
||||||
name: `Test Tournament ${timestamp}`,
|
name: `Test Tournament ${timestamp}`,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
ownerId: userId, // Set the owner to the current user
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -409,11 +438,18 @@ Given('a tournament has a generated schedule', async function () {
|
|||||||
const prisma = await world.getPrisma();
|
const prisma = await world.getPrisma();
|
||||||
const timestamp = Date.now();
|
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
|
// Create a tournament
|
||||||
const tournament = await prisma.event.create({
|
const tournament = await prisma.event.create({
|
||||||
data: {
|
data: {
|
||||||
name: `Test Schedule Tournament ${timestamp}`,
|
name: `Test Schedule Tournament ${timestamp}`,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
ownerId: userId, // Set the owner to the current user
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ When('I go back', async function () {
|
|||||||
|
|
||||||
When('I refresh the page', async function () {
|
When('I refresh the page', async function () {
|
||||||
console.log('🌍 About to refresh page from URL:', world.page.url());
|
console.log('🌍 About to refresh page from URL:', world.page.url());
|
||||||
await world.page.reload({ waitUntil: 'domcontentloaded' });
|
await world.page.reload({ waitUntil: 'networkidle' });
|
||||||
console.log('🌍 Page refreshed, new URL:', world.page.url());
|
console.log('🌍 Page refreshed, new URL:', world.page.url());
|
||||||
// Wait extra time for full render
|
// Wait extra time for full render
|
||||||
await world.page.waitForTimeout(2000);
|
await world.page.waitForTimeout(2000);
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ After(async function () {
|
|||||||
select: { id: true }
|
select: { id: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
const tournamentIds = testTournaments.map(t => t.id);
|
const tournamentIds = testTournaments.map((t: { id: number }) => t.id);
|
||||||
|
|
||||||
if (tournamentIds.length > 0) {
|
if (tournamentIds.length > 0) {
|
||||||
// Delete bracket matchups via Prisma
|
// Delete bracket matchups via Prisma
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface WorldState {
|
|||||||
prisma: any; // Lazy-loaded PrismaClient
|
prisma: any; // Lazy-loaded PrismaClient
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
user?: {
|
user?: {
|
||||||
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -32,6 +33,7 @@ export class World implements WorldState {
|
|||||||
prisma: any;
|
prisma: any;
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
user?: {
|
user?: {
|
||||||
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -85,17 +85,23 @@ test-acceptance-postgres:
|
|||||||
|
|
||||||
# Run Cucumber e2e tests with SQLite
|
# Run Cucumber e2e tests with SQLite
|
||||||
test-cucumber-sqlite:
|
test-cucumber-sqlite:
|
||||||
|
@echo "Clearing Next.js cache..."
|
||||||
|
rm -rf .next/
|
||||||
@echo "Running Cucumber e2e tests with SQLite..."
|
@echo "Running Cucumber e2e tests with SQLite..."
|
||||||
DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db npm run test:acceptance:cucumber
|
DATABASE_PROVIDER=sqlite DATABASE_URL=file:./prisma/ci.db npm run test:acceptance:cucumber
|
||||||
|
|
||||||
# Run Cucumber e2e tests with PostgreSQL (uses .env.development)
|
# Run Cucumber e2e tests with PostgreSQL (uses .env.development)
|
||||||
test-cucumber-postgres:
|
test-cucumber-postgres:
|
||||||
|
@echo "Clearing Next.js cache..."
|
||||||
|
rm -rf .next/
|
||||||
@echo "Running Cucumber e2e tests with PostgreSQL..."
|
@echo "Running Cucumber e2e tests with PostgreSQL..."
|
||||||
npm run test:acceptance:cucumber
|
npm run test:acceptance:cucumber
|
||||||
|
|
||||||
# Run Cucumber e2e tests with PostgreSQL against production build
|
# Run Cucumber e2e tests with PostgreSQL against production build
|
||||||
# This is more reliable than dev server (no HMR, faster API responses)
|
# This is more reliable than dev server (no HMR, faster API responses)
|
||||||
test-cucumber-postgres-prod:
|
test-cucumber-postgres-prod:
|
||||||
|
@echo "Clearing Next.js cache..."
|
||||||
|
rm -rf .next/
|
||||||
@echo "Building application for production..."
|
@echo "Building application for production..."
|
||||||
bun run build
|
bun run build
|
||||||
@echo "Starting production server in background..."
|
@echo "Starting production server in background..."
|
||||||
@@ -211,7 +217,7 @@ help:
|
|||||||
# Clean up project (remove node_modules, build artifacts)
|
# Clean up project (remove node_modules, build artifacts)
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning project..."
|
@echo "Cleaning project..."
|
||||||
rm -rf node_modules .next dist
|
rm -rf node_modules .next dist .turbo
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
prisma-generate:
|
prisma-generate:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[Schedule Page] Fetching tournament ${tournamentId}`);
|
||||||
const tournament = await prisma.event.findUnique({
|
const tournament = await prisma.event.findUnique({
|
||||||
where: { id: tournamentId },
|
where: { id: tournamentId },
|
||||||
include: {
|
include: {
|
||||||
@@ -48,6 +49,10 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
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) {
|
if (!tournament) {
|
||||||
notFound()
|
notFound()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { canManageTournament } from "@/lib/permissions";
|
import { canManageTournament } from "@/lib/permissions";
|
||||||
import { generateRoundRobin, validateScheduleInput, generateVariableRoundRobin, expectedRounds } from "@/lib/schedule-generator";
|
import { generateRoundRobin, validateScheduleInput, generateVariableRoundRobin, expectedRounds } from "@/lib/schedule-generator";
|
||||||
@@ -78,11 +79,15 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
|||||||
* Creates TournamentRound and BracketMatchup records.
|
* Creates TournamentRound and BracketMatchup records.
|
||||||
*/
|
*/
|
||||||
export async function POST(_request: Request, { params }: RouteParams) {
|
export async function POST(_request: Request, { params }: RouteParams) {
|
||||||
|
console.log(`[Schedule API] POST handler started`);
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const tournamentId = parseInt(id);
|
const tournamentId = parseInt(id);
|
||||||
|
|
||||||
|
console.log(`[Schedule API] POST /api/tournaments/${tournamentId}/schedule`);
|
||||||
|
|
||||||
if (isNaN(tournamentId)) {
|
if (isNaN(tournamentId)) {
|
||||||
|
console.log(`[Schedule API] Invalid tournament ID: ${id}`);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid tournament ID" },
|
{ error: "Invalid tournament ID" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -90,6 +95,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const permission = await canManageTournament(tournamentId);
|
const permission = await canManageTournament(tournamentId);
|
||||||
|
console.log(`[Schedule API] Permission check: ${permission.allowed}, reason: ${permission.reason}`);
|
||||||
if (!permission.allowed) {
|
if (!permission.allowed) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: permission.reason || "Not authorized to manage this tournament" },
|
{ error: permission.reason || "Not authorized to manage this tournament" },
|
||||||
@@ -98,6 +104,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check tournament exists
|
// Check tournament exists
|
||||||
|
console.log(`[Schedule API] Looking up tournament ${tournamentId}`);
|
||||||
const tournament = await prisma.event.findUnique({
|
const tournament = await prisma.event.findUnique({
|
||||||
where: { id: tournamentId },
|
where: { id: tournamentId },
|
||||||
include: {
|
include: {
|
||||||
@@ -111,14 +118,17 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!tournament) {
|
if (!tournament) {
|
||||||
|
console.log(`[Schedule API] Tournament ${tournamentId} not found`);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Tournament not found" },
|
{ error: "Tournament not found" },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
console.log(`[Schedule API] Found tournament ${tournamentId} with ${tournament.participants.length} participants and ${tournament.rounds.length} existing rounds`);
|
||||||
|
|
||||||
// Check if schedule already exists and delete it
|
// Check if schedule already exists and delete it
|
||||||
if (tournament.rounds.length > 0) {
|
if (tournament.rounds.length > 0) {
|
||||||
|
console.log(`[Schedule API] Deleting ${tournament.rounds.length} existing rounds`);
|
||||||
// Delete existing rounds and matchups before regenerating
|
// Delete existing rounds and matchups before regenerating
|
||||||
await prisma.bracketMatchup.deleteMany({
|
await prisma.bracketMatchup.deleteMany({
|
||||||
where: { eventId: tournamentId },
|
where: { eventId: tournamentId },
|
||||||
@@ -135,6 +145,8 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
currentElo: p.player.currentElo,
|
currentElo: p.player.currentElo,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console.log(`[Schedule API] Got ${participants.length} participants`);
|
||||||
|
|
||||||
// Check minimum participants
|
// Check minimum participants
|
||||||
if (participants.length < 2) {
|
if (participants.length < 2) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -147,20 +159,24 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
const teamDurability = tournament.teamDurability || "permanent";
|
const teamDurability = tournament.teamDurability || "permanent";
|
||||||
const partnerRotation = (tournament.partnerRotation || "none") as 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based';
|
const partnerRotation = (tournament.partnerRotation || "none") as 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based';
|
||||||
const allowByes = tournament.allowByes ?? true;
|
const allowByes = tournament.allowByes ?? true;
|
||||||
|
console.log(`[Schedule API] Team durability: ${teamDurability}, partner rotation: ${partnerRotation}, allow byes: ${allowByes}`);
|
||||||
|
|
||||||
// Determine number of teams from participants
|
// Determine number of teams from participants
|
||||||
const tempResult = generateTeams(participants, partnerRotation, allowByes);
|
const tempResult = generateTeams(participants, partnerRotation, allowByes);
|
||||||
const teamCount = tempResult.teams.length;
|
const teamCount = tempResult.teams.length;
|
||||||
|
console.log(`[Schedule API] Generated ${teamCount} teams from ${participants.length} participants`);
|
||||||
|
|
||||||
if (teamCount < 2) {
|
if (teamCount < 2) {
|
||||||
|
console.log(`[Schedule API] Not enough teams: ${teamCount}`);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "At least 2 teams (4 players) are required to generate a schedule" },
|
{ error: "At least 2 teams are required to generate a schedule" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate number of rounds needed
|
// Calculate expected rounds
|
||||||
const numRounds = expectedRounds(teamCount);
|
const expectedRounds = expectedRounds(teamCount);
|
||||||
|
console.log(`[Schedule API] Expected rounds: ${expectedRounds}`);
|
||||||
|
|
||||||
if (teamDurability === "permanent") {
|
if (teamDurability === "permanent") {
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -186,11 +202,14 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
|
|
||||||
// Generate schedule using fixed teams
|
// Generate schedule using fixed teams
|
||||||
const schedule = generateRoundRobin(teamPairings);
|
const schedule = generateRoundRobin(teamPairings);
|
||||||
|
console.log(`[Schedule API] Generated ${schedule.length} rounds for ${teamCount} teams (fixed)`);
|
||||||
|
|
||||||
// Create rounds and matchups in a transaction
|
// Create rounds and matchups in a transaction
|
||||||
|
console.log(`[Schedule API] About to create ${schedule.length} rounds in transaction`);
|
||||||
const created = await prisma.$transaction(
|
const created = await prisma.$transaction(
|
||||||
schedule.map((round) =>
|
schedule.map((round) => {
|
||||||
prisma.tournamentRound.create({
|
console.log(`[Schedule API] Creating round ${round.roundNumber} with ${round.matchups.length} matchups`);
|
||||||
|
return prisma.tournamentRound.create({
|
||||||
data: {
|
data: {
|
||||||
eventId: tournamentId,
|
eventId: tournamentId,
|
||||||
roundNumber: round.roundNumber,
|
roundNumber: round.roundNumber,
|
||||||
@@ -218,9 +237,18 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`[Schedule API] Transaction complete. Created ${created.length} rounds`);
|
||||||
|
console.log(`[Schedule API] Verifying in database:`);
|
||||||
|
for (const round of created) {
|
||||||
|
console.log(`[Schedule API] Round ${round.roundNumber}: id=${round.id}, eventId=${round.eventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/admin/tournaments/${tournamentId}/schedule`);
|
||||||
|
console.log(`[Schedule API] revalidatePath called for /admin/tournaments/${tournamentId}/schedule`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
roundsCreated: created.length,
|
roundsCreated: created.length,
|
||||||
@@ -257,7 +285,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
const schedule = generateVariableRoundRobin(
|
const schedule = generateVariableRoundRobin(
|
||||||
participants,
|
participants,
|
||||||
teamCount,
|
teamCount,
|
||||||
numRounds,
|
expectedRounds,
|
||||||
generateTeamWithRotation
|
generateTeamWithRotation
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -295,6 +323,8 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
revalidatePath(`/admin/tournaments/${tournamentId}/schedule`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
roundsCreated: created.length,
|
roundsCreated: created.length,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import type { Player, Match } from "@prisma/client"
|
import type { Player, Match } from "@prisma/client"
|
||||||
|
|
||||||
interface MatchEditorProps {
|
interface MatchEditorProps {
|
||||||
@@ -40,6 +41,7 @@ export default function MatchEditor({
|
|||||||
prefilledP4,
|
prefilledP4,
|
||||||
prefilledRound,
|
prefilledRound,
|
||||||
}: MatchEditorProps) {
|
}: MatchEditorProps) {
|
||||||
|
const router = useRouter()
|
||||||
// Check if players are prefilled from URL params
|
// Check if players are prefilled from URL params
|
||||||
const hasPrefilledPlayers = prefilledP1 && prefilledP2 && prefilledP3 && prefilledP4;
|
const hasPrefilledPlayers = prefilledP1 && prefilledP2 && prefilledP3 && prefilledP4;
|
||||||
|
|
||||||
@@ -170,9 +172,9 @@ export default function MatchEditor({
|
|||||||
isCasual: false,
|
isCasual: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reload the page to show updated matches
|
// Refresh the page to show updated matches
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload()
|
router.refresh()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
} catch {
|
} catch {
|
||||||
setError("An error occurred. Please try again.")
|
setError("An error occurred. Please try again.")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
export function RecalculateEloButton() {
|
export function RecalculateEloButton() {
|
||||||
|
const router = useRouter()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
@@ -33,7 +35,7 @@ export function RecalculateEloButton() {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(`Recalculation completed: ${JSON.stringify(data.data)}`)
|
alert(`Recalculation completed: ${JSON.stringify(data.data)}`)
|
||||||
window.location.reload()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
alert(`Error: ${data.error}`)
|
alert(`Error: ${data.error}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
interface ScheduleGeneratorProps {
|
interface ScheduleGeneratorProps {
|
||||||
tournamentId: number
|
tournamentId: number
|
||||||
@@ -9,6 +10,7 @@ interface ScheduleGeneratorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: ScheduleGeneratorProps) {
|
export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: ScheduleGeneratorProps) {
|
||||||
|
const router = useRouter()
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [result, setResult] = useState<{
|
const [result, setResult] = useState<{
|
||||||
@@ -47,6 +49,9 @@ export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: S
|
|||||||
matchupsCreated: data.matchupsCreated,
|
matchupsCreated: data.matchupsCreated,
|
||||||
})
|
})
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
|
|
||||||
|
// Re-fetch the schedule data from the server
|
||||||
|
router.refresh()
|
||||||
} catch {
|
} catch {
|
||||||
setError("An error occurred. Please try again.")
|
setError("An error occurred. Please try again.")
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
@@ -81,7 +86,7 @@ export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: S
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.reload()
|
router.refresh()
|
||||||
} catch {
|
} catch {
|
||||||
setError("An error occurred. Please try again.")
|
setError("An error occurred. Please try again.")
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user