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
|
||||
* 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}`);
|
||||
});
|
||||
|
||||
@@ -359,11 +381,18 @@ Given('a tournament exists with {int} teams', async function (teamCount: number)
|
||||
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.');
|
||||
}
|
||||
|
||||
// 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
|
||||
},
|
||||
});
|
||||
|
||||
@@ -409,11 +438,18 @@ Given('a tournament has a generated schedule', async function () {
|
||||
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
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ When('I go back', async function () {
|
||||
|
||||
When('I refresh the page', async function () {
|
||||
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());
|
||||
// Wait extra time for full render
|
||||
await world.page.waitForTimeout(2000);
|
||||
|
||||
@@ -141,7 +141,7 @@ After(async function () {
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
const tournamentIds = testTournaments.map(t => t.id);
|
||||
const tournamentIds = testTournaments.map((t: { id: number }) => t.id);
|
||||
|
||||
if (tournamentIds.length > 0) {
|
||||
// Delete bracket matchups via Prisma
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -85,17 +85,23 @@ 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..."
|
||||
@@ -211,7 +217,7 @@ help:
|
||||
# Clean up project (remove node_modules, build artifacts)
|
||||
clean:
|
||||
@echo "Cleaning project..."
|
||||
rm -rf node_modules .next dist
|
||||
rm -rf node_modules .next dist .turbo
|
||||
|
||||
# Generate Prisma client
|
||||
prisma-generate:
|
||||
|
||||
@@ -23,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: {
|
||||
@@ -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) {
|
||||
notFound()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canManageTournament } from "@/lib/permissions";
|
||||
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.
|
||||
*/
|
||||
export async function POST(_request: Request, { params }: RouteParams) {
|
||||
console.log(`[Schedule API] POST handler started`);
|
||||
try {
|
||||
const { id } = await params;
|
||||
const tournamentId = parseInt(id);
|
||||
|
||||
console.log(`[Schedule API] POST /api/tournaments/${tournamentId}/schedule`);
|
||||
|
||||
if (isNaN(tournamentId)) {
|
||||
console.log(`[Schedule API] Invalid tournament ID: ${id}`);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid tournament ID" },
|
||||
{ status: 400 }
|
||||
@@ -90,6 +95,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const permission = await canManageTournament(tournamentId);
|
||||
console.log(`[Schedule API] Permission check: ${permission.allowed}, reason: ${permission.reason}`);
|
||||
if (!permission.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: permission.reason || "Not authorized to manage this tournament" },
|
||||
@@ -98,6 +104,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
// Check tournament exists
|
||||
console.log(`[Schedule API] Looking up tournament ${tournamentId}`);
|
||||
const tournament = await prisma.event.findUnique({
|
||||
where: { id: tournamentId },
|
||||
include: {
|
||||
@@ -111,14 +118,17 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
||||
});
|
||||
|
||||
if (!tournament) {
|
||||
console.log(`[Schedule API] Tournament ${tournamentId} not found`);
|
||||
return NextResponse.json(
|
||||
{ error: "Tournament not found" },
|
||||
{ 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
|
||||
if (tournament.rounds.length > 0) {
|
||||
console.log(`[Schedule API] Deleting ${tournament.rounds.length} existing rounds`);
|
||||
// Delete existing rounds and matchups before regenerating
|
||||
await prisma.bracketMatchup.deleteMany({
|
||||
where: { eventId: tournamentId },
|
||||
@@ -135,6 +145,8 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
||||
currentElo: p.player.currentElo,
|
||||
}));
|
||||
|
||||
console.log(`[Schedule API] Got ${participants.length} participants`);
|
||||
|
||||
// Check minimum participants
|
||||
if (participants.length < 2) {
|
||||
return NextResponse.json(
|
||||
@@ -147,20 +159,24 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
||||
const teamDurability = tournament.teamDurability || "permanent";
|
||||
const partnerRotation = (tournament.partnerRotation || "none") as 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based';
|
||||
const allowByes = tournament.allowByes ?? true;
|
||||
console.log(`[Schedule API] Team durability: ${teamDurability}, partner rotation: ${partnerRotation}, allow byes: ${allowByes}`);
|
||||
|
||||
// Determine number of teams from participants
|
||||
const tempResult = generateTeams(participants, partnerRotation, allowByes);
|
||||
const teamCount = tempResult.teams.length;
|
||||
|
||||
console.log(`[Schedule API] Generated ${teamCount} teams from ${participants.length} participants`);
|
||||
|
||||
if (teamCount < 2) {
|
||||
console.log(`[Schedule API] Not enough teams: ${teamCount}`);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate number of rounds needed
|
||||
const numRounds = expectedRounds(teamCount);
|
||||
// Calculate expected rounds
|
||||
const expectedRounds = expectedRounds(teamCount);
|
||||
console.log(`[Schedule API] Expected rounds: ${expectedRounds}`);
|
||||
|
||||
if (teamDurability === "permanent") {
|
||||
// ============================================
|
||||
@@ -186,11 +202,14 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
||||
|
||||
// Generate schedule using fixed teams
|
||||
const schedule = generateRoundRobin(teamPairings);
|
||||
console.log(`[Schedule API] Generated ${schedule.length} rounds for ${teamCount} teams (fixed)`);
|
||||
|
||||
// Create rounds and matchups in a transaction
|
||||
console.log(`[Schedule API] About to create ${schedule.length} rounds in transaction`);
|
||||
const created = await prisma.$transaction(
|
||||
schedule.map((round) =>
|
||||
prisma.tournamentRound.create({
|
||||
schedule.map((round) => {
|
||||
console.log(`[Schedule API] Creating round ${round.roundNumber} with ${round.matchups.length} matchups`);
|
||||
return prisma.tournamentRound.create({
|
||||
data: {
|
||||
eventId: tournamentId,
|
||||
roundNumber: round.roundNumber,
|
||||
@@ -218,8 +237,17 @@ 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({
|
||||
success: true,
|
||||
@@ -257,7 +285,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
||||
const schedule = generateVariableRoundRobin(
|
||||
participants,
|
||||
teamCount,
|
||||
numRounds,
|
||||
expectedRounds,
|
||||
generateTeamWithRotation
|
||||
);
|
||||
|
||||
@@ -295,6 +323,8 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath(`/admin/tournaments/${tournamentId}/schedule`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
roundsCreated: created.length,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import type { Player, Match } from "@prisma/client"
|
||||
|
||||
interface MatchEditorProps {
|
||||
@@ -40,6 +41,7 @@ export default function MatchEditor({
|
||||
prefilledP4,
|
||||
prefilledRound,
|
||||
}: MatchEditorProps) {
|
||||
const router = useRouter()
|
||||
// Check if players are prefilled from URL params
|
||||
const hasPrefilledPlayers = prefilledP1 && prefilledP2 && prefilledP3 && prefilledP4;
|
||||
|
||||
@@ -170,9 +172,9 @@ export default function MatchEditor({
|
||||
isCasual: false,
|
||||
})
|
||||
|
||||
// Reload the page to show updated matches
|
||||
// Refresh the page to show updated matches
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
router.refresh()
|
||||
}, 1000)
|
||||
} catch {
|
||||
setError("An error occurred. Please try again.")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export function RecalculateEloButton() {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
@@ -33,7 +35,7 @@ export function RecalculateEloButton() {
|
||||
|
||||
if (data.success) {
|
||||
alert(`Recalculation completed: ${JSON.stringify(data.data)}`)
|
||||
window.location.reload()
|
||||
router.refresh()
|
||||
} else {
|
||||
alert(`Error: ${data.error}`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface ScheduleGeneratorProps {
|
||||
tournamentId: number
|
||||
@@ -9,6 +10,7 @@ interface ScheduleGeneratorProps {
|
||||
}
|
||||
|
||||
export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: ScheduleGeneratorProps) {
|
||||
const router = useRouter()
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [result, setResult] = useState<{
|
||||
@@ -47,6 +49,9 @@ export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: S
|
||||
matchupsCreated: data.matchupsCreated,
|
||||
})
|
||||
setIsGenerating(false)
|
||||
|
||||
// Re-fetch the schedule data from the server
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError("An error occurred. Please try again.")
|
||||
setIsGenerating(false)
|
||||
@@ -81,7 +86,7 @@ export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: S
|
||||
return
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError("An error occurred. Please try again.")
|
||||
setIsGenerating(false)
|
||||
|
||||
Reference in New Issue
Block a user