1 Commits

Author SHA1 Message Date
david 719b7928e6 test: enable password reset page test and add navigation step
Pull Request / unit-tests (pull_request) Successful in 1m9s
Pull Request / e2e-tests (pull_request) Failing after 3m2s
Pull Request / analyze-bump-type (pull_request) Has been skipped
Related to #10

- Added Given step for password reset page navigation
- Password reset page access test is now active (passes)
- Email validation and submission tests remain @wip (stub implementation)
- Added step definition for navigating to /auth/password-reset

The password reset page exists at /auth/password-reset but is a stub
(always shows success). Full implementation needed to un-wip remaining tests.
2026-04-26 20:16:46 -07:00
29 changed files with 10193 additions and 1320 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ on:
paths: paths:
- 'Dockerfile.ci-base' - 'Dockerfile.ci-base'
- 'package.json' - 'package.json'
- 'bun.lock' - 'bun.lockb'
- '.gitea/workflows/build-ci-images.yml' - '.gitea/workflows/build-ci-images.yml'
schedule: schedule:
# Weekly rebuild to get latest Playwright/Bun versions # Weekly rebuild to get latest Playwright/Bun versions
-6
View File
@@ -16,9 +16,6 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Clear Bun cache
run: bun pm cache rm || true
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
@@ -41,9 +38,6 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Clear Bun cache
run: bun pm cache rm || true
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
-51
View File
@@ -1,54 +1,3 @@
## [0.1.14] - 2026-05-02
### Patch Changes
- Merge branch 'bugfix/7-tournament-schedule-tests': Schedule generation, clickable matchups, and test fixes
- Merge branch 'bugfix/9-player-schedule-tests': Player schedule clickable matches
- Merge branch 'bugfix/10-password-reset-tests': Password reset API and form wiring
- fix: resolve schedule generation tests - round display, clickable links, and team count
- fix: rename variable to avoid shadowing expectedRounds function
- fix: improve link click handling to wait for networkidle
- feat: implement password reset API endpoint and wire up form
- fix: make player schedule matches clickable links to match detail page
- fix: support matchup query param for direct navigation to entry page
- fix: correct wordmark link to point to home page
- fix: resolve schedule data staleness in production builds
- wip: Tournament schedule tests - 27/30 passing
- feat: add ScheduleDisplay component and wire up schedule page with Generator
- test: add tournament schedule step definitions
- test: enable player schedule tests with match data setup
- test: enable password reset page test and add navigation step
## [0.1.13] - 2026-04-27
### Patch Changes
- ci: update Playwright to v1.59.1 in CI base image
## [0.1.12] - 2026-04-27
### Patch Changes
- ci: clear Bun cache before install to fix integrity check failures
## [0.1.11] - 2026-04-27
### Patch Changes
## [0.1.10] - 2026-04-27
### Patch Changes
- fix: prevent content overflow on right side of screen
## [0.1.9] - 2026-04-27
### Patch Changes
- test: remove migrated Playwright tests (epic3-rankings, home-page)
## [0.1.8] - 2026-04-27 ## [0.1.8] - 2026-04-27
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -2,7 +2,7 @@
# Used for Gitea Actions CI workflows # Used for Gitea Actions CI workflows
# Uses Microsoft Playwright image as base (Ubuntu-based) with Bun added # Uses Microsoft Playwright image as base (Ubuntu-based) with Bun added
FROM mcr.microsoft.com/playwright:v1.59.1-jammy AS base FROM mcr.microsoft.com/playwright:v1.58.0-jammy AS base
# Install unzip (required for Bun installation) and other tools # Install unzip (required for Bun installation) and other tools
RUN apt-get update && apt-get install -y unzip && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y unzip && rm -rf /var/lib/apt/lists/*
+222 -222
View File
File diff suppressed because it is too large Load Diff
-343
View File
@@ -1,343 +0,0 @@
# 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
@@ -9,7 +9,7 @@ Feature: Player Schedule
When I go to my schedule page When I go to my schedule page
Then I should see "No upcoming matches" Then I should see "No upcoming matches"
@happy-path @player-features @issue-9 @happy-path @player-features @issue-9 @wip
Scenario: Player views schedule with upcoming matches Scenario: Player views schedule with upcoming matches
Given I am logged in as a player Given I am logged in as a player
And I have upcoming matches in my schedule And I have upcoming matches in my schedule
@@ -11,39 +11,29 @@ Feature: Tournament Schedule
Then I should see "Schedule" Then I should see "Schedule"
And I should see the "Generate Schedule" button And I should see the "Generate Schedule" button
@happy-path @tournament @issue-7 @happy-path @tournament @issue-7 @wip
Scenario: Tournament admin generates round-robin schedule Scenario: Tournament admin generates round-robin schedule
Given I am logged in as a tournament admin Given I am logged in as a tournament admin
And a tournament exists with 4 teams And a tournament exists with 4 teams
When I go to the tournament schedule page When I go to the tournament schedule page
And I click the "Generate Schedule" button And I click the "Generate Schedule" button
Then I should see "Generated" Then I should see "Schedule generated successfully"
And I should see "rounds with" And I should see round 1 matchups
# 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 And I should see round 2 matchups
@happy-path @tournament @issue-7 @happy-path @tournament @issue-7 @wip
Scenario: Tournament admin views schedule with bye rounds Scenario: Tournament admin views schedule with bye rounds
Given I am logged in as a tournament admin Given I am logged in as a tournament admin
And a tournament exists with 5 teams And a tournament exists with 5 teams
When I go to the tournament schedule page When I go to the tournament schedule page
And I click the "Generate Schedule" button And I click the "Generate Schedule" button
Then I should see "Generated" Then I should see a bye round for one team
# 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 And each team should play every other team exactly once
@happy-path @tournament @issue-7 @happy-path @tournament @issue-7 @wip
Scenario: Tournament admin clicks on a matchup to enter results Scenario: Tournament admin clicks on a matchup to enter results
Given I am logged in as a tournament admin Given I am logged in as a tournament admin
And a tournament exists with 4 teams And a tournament has a generated schedule
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 When I go to the tournament schedule page
And I click on a matchup And I click on a matchup
Then I should be on the match result entry page Then I should be on the match result entry page
+34 -192
View File
@@ -108,10 +108,16 @@ 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 assign the tournament_admin role directly via Prisma. * For acceptance tests, we'll use the default player role and test admin features
* 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 tournament admin...'); 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
const credentials = generateTestCredentials(); const credentials = generateTestCredentials();
world.user = credentials; world.user = credentials;
@@ -127,34 +133,6 @@ 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,67 +337,16 @@ When('I go to my schedule page', async function () {
}); });
Given('I have upcoming matches in my schedule', async function () { Given('I have upcoming matches in my schedule', async function () {
console.log('🌍 Setting up upcoming matches in schedule'); console.log('🌍 Note: This step requires database setup via API or UI');
const prisma = await world.getPrisma(); console.log('🌍 For acceptance tests, this would be set up before running the test');
const timestamp = Date.now(); // For true acceptance testing, we would:
// 1. Create a tournament
// 2. Add the player as a participant
// 3. Generate a schedule
// 4. The match would then appear in the player's schedule
// Get the current player // For now, this is a placeholder that indicates data setup is needed
if (!world.playerId) { // In a real test run, this data would already exist in the dev database
throw new Error('No player ID found. Make sure user is logged in as a player first.');
}
const currentPlayerId = parseInt(world.playerId, 10);
// Create 3 other players for the match
const opponent1 = await prisma.player.create({
data: {
name: `Opponent ${timestamp} 1`,
normalizedName: `opponent ${timestamp} 1`,
currentElo: 1000,
},
});
const opponent2 = await prisma.player.create({
data: {
name: `Opponent ${timestamp} 2`,
normalizedName: `opponent ${timestamp} 2`,
currentElo: 1000,
},
});
const partner1 = await prisma.player.create({
data: {
name: `Partner ${timestamp}`,
normalizedName: `partner ${timestamp}`,
currentElo: 1000,
},
});
// Create a tournament
const tournament = await prisma.event.create({
data: {
name: `Test Schedule Tournament ${timestamp}`,
eventDate: new Date(Date.now() + 86400000), // Tomorrow
status: 'planned',
},
});
// Create a match with the current player as player1P1 (played tomorrow)
await prisma.match.create({
data: {
eventId: tournament.id,
player1P1Id: currentPlayerId,
player1P2Id: partner1.id,
player2P1Id: opponent1.id,
player2P2Id: opponent2.id,
team1Score: 10,
team2Score: 5,
status: 'completed',
playedAt: new Date(Date.now() + 86400000), // Tomorrow
},
});
console.log(`🌍 Created tournament "${tournament.name}" with 1 match for player ${currentPlayerId}`);
}); });
/** /**
@@ -430,42 +357,19 @@ Given('a tournament exists with {int} teams', async function (teamCount: number)
// Get Prisma client // Get Prisma client
const prisma = await world.getPrisma(); const prisma = await world.getPrisma();
const timestamp = Date.now();
// Get the current user ID for ownership // Find or create a tournament
const userId = world.user?.id; let tournament = await prisma.event.findFirst({
if (!userId) { orderBy: { createdAt: 'desc' },
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
},
}); });
// Euchre is 2v2, so each team has 2 players if (!tournament) {
// Create teamCount * 2 players and add them as participants // Create a new tournament if none exists
const playerCount = teamCount * 2; const timestamp = Date.now();
for (let i = 1; i <= playerCount; i++) { tournament = await prisma.event.create({
const player = await prisma.player.create({
data: { data: {
name: `Tournament Player ${i} ${timestamp}`, name: `Test Tournament ${timestamp}`,
normalizedName: `tournament player ${i} ${timestamp}`, createdAt: new Date(),
currentElo: 1000,
gamesPlayed: 0,
wins: 0,
losses: 0,
},
});
await prisma.eventParticipant.create({
data: {
eventId: tournament.id,
playerId: player.id,
}, },
}); });
} }
@@ -473,85 +377,23 @@ Given('a tournament exists with {int} teams', async function (teamCount: number)
world.tournament = tournament; world.tournament = tournament;
world.tournamentTeamCount = teamCount; world.tournamentTeamCount = teamCount;
console.log(`🌍 Created tournament: ${tournament.name} (ID: ${tournament.id}) with ${playerCount} players (${teamCount} teams)`); console.log(`🌍 Using tournament: ${tournament.name} (ID: ${tournament.id})`);
}); });
When('I go to the tournament schedule page', async function () { When('I go to the tournament schedule page', async function () {
console.log('🌍 Going to tournament schedule page'); console.log('🌍 Going to tournament schedule page');
const tournamentId = world.tournament?.id || 1; const tournamentId = world.tournament?.id || 1;
await world.page.goto(`${world.baseURL}/admin/tournaments/${tournamentId}/schedule`); await world.page.goto(`${world.baseURL}/admin/tournaments/${tournamentId}/schedule`);
await world.page.waitForLoadState('load'); await world.page.waitForLoadState('domcontentloaded');
// Wait for client components to hydrate
await world.page.waitForTimeout(1000);
}); });
Given('a tournament has a generated schedule', async function () { Given('a tournament has a generated schedule', async function () {
console.log('🌍 Creating tournament with generated schedule'); console.log('🌍 Note: Tournament schedule requires generation via API or UI');
console.log('🌍 For acceptance tests, this would be created before running the test');
const prisma = await world.getPrisma(); // In a real test run, we would:
const timestamp = Date.now(); // 1. Create a tournament
// 2. Add teams/participants
// Get the current user ID for ownership // 3. Generate schedule via API or UI
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 () { Given('there are recent activities in the system', async function () {
+19 -104
View File
@@ -109,14 +109,8 @@ 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()); await world.page.reload();
await world.page.reload({ waitUntil: 'networkidle' }); await world.page.waitForLoadState('domcontentloaded');
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'));
}); });
/** /**
@@ -186,18 +180,28 @@ When('I click the {string} link', async function (linkText: string) {
const selector = `a:has-text("${linkText}")`; const selector = `a:has-text("${linkText}")`;
console.log(`🌍 Clicking link: ${linkText}`); console.log(`🌍 Clicking link: ${linkText}`);
// Get current URL
const currentUrl = world.page.url();
// Click the link // Click the link
await world.page.click(selector); await world.page.click(selector);
// Wait for navigation to complete // Wait a bit for navigation to start
try { await world.page.waitForTimeout(500);
await world.page.waitForLoadState('networkidle', { timeout: 10000 });
} catch {
console.log(`🌍 Networkidle not reached, continuing`);
}
// Check if URL changed
const newUrl = world.page.url(); const newUrl = world.page.url();
console.log(`🌍 Page navigated to: ${newUrl}`); if (newUrl === currentUrl) {
console.log(`🌍 URL did not change immediately after link click`);
// Wait for any navigation to complete
try {
await world.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
} catch {
console.log(`🌍 DOMContentLoaded not reached, continuing`);
}
} else {
console.log(`🌍 Page navigated to: ${newUrl}`);
}
}); });
When('I click the {string} wordmark', async function (wordmarkText: string) { When('I click the {string} wordmark', async function (wordmarkText: string) {
@@ -601,92 +605,3 @@ Then('I should see the rankings table', async function () {
await expect(world.page.locator('table')).toBeVisible(); await expect(world.page.locator('table')).toBeVisible();
console.log('🌍 Verified rankings table is visible'); console.log('🌍 Verified rankings table is visible');
}); });
// Player Schedule Steps
Then('I should see the match date', async function () {
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();
console.log('🌍 Verified match date is visible');
});
Then('I should see my opponent\'s name', async function () {
const content = await world.page.content();
const hasOpponent = content.includes('Opponent');
expect(hasOpponent).toBe(true);
console.log('🌍 Verified opponent name is visible');
});
Then('I should see my partner\'s name', async function () {
const content = await world.page.content();
const hasPartner = content.includes('Partner');
expect(hasPartner).toBe(true);
console.log('🌍 Verified partner name is visible');
});
Then('I should see the tournament name', async function () {
const content = await world.page.content();
const hasTournament = content.includes('Test Schedule Tournament');
expect(hasTournament).toBe(true);
console.log('🌍 Verified tournament name is visible');
});
When('I click on a match', async function () {
const matchLink = world.page.locator('a[href*="/matches/"]').first();
await matchLink.click();
await world.page.waitForLoadState('domcontentloaded');
console.log('🌍 Clicked on match');
});
Then('I should be on the match detail page', async function () {
const currentUrl = world.page.url();
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)/);
});
+1 -76
View File
@@ -116,86 +116,11 @@ Before(async function () {
}); });
/** /**
* After each scenario: Close page and clean up test data * After each scenario: Close page
*/ */
After(async function () { After(async function () {
console.log('🌍 Cleaning up after scenario...'); 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 // Close page and context
if (world.page) { if (world.page) {
await world.page.close(); await world.page.close();
+7 -6
View File
@@ -11,7 +11,6 @@ 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;
@@ -33,7 +32,6 @@ 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;
@@ -62,11 +60,14 @@ export class World implements WorldState {
if (!process.env.DATABASE_URL) { 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.'); 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';
// Use the shared prisma instance from the app's lib // Import PrismaClient AFTER setting environment variables
// This handles the adapter setup correctly const { PrismaClient } = await import('@prisma/client');
const { prisma } = require('@/lib/prisma'); const { PrismaPg } = await import('@prisma/adapter-pg');
this.prisma = prisma;
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
this.prisma = new PrismaClient({ adapter });
} }
return this.prisma; return this.prisma;
} }
+4 -8
View File
@@ -85,27 +85,21 @@ 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..."
DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '"') DATABASE_PROVIDER=postgresql bun run start > /tmp/next-prod.log 2>&1 & bun run start > /tmp/next-prod.log 2>&1 &
SERVER_PID=$$! SERVER_PID=$$!
@echo "Waiting for server to be ready..." @echo "Waiting for server to be ready..."
sleep 15 sleep 15
@@ -217,7 +211,9 @@ 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 .turbo rm -rf node_modules .next dist
@echo "Cleaning Docker artifacts..."
docker system prune -f
# Generate Prisma client # Generate Prisma client
prisma-generate: prisma-generate:
+9840
View File
File diff suppressed because it is too large Load Diff
+19 -19
View File
@@ -1,6 +1,6 @@
{ {
"name": "euchre_camp", "name": "euchre_camp",
"version": "0.1.14", "version": "0.1.8",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev", "dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev",
@@ -44,35 +44,35 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.6.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.6.9", "better-auth": "^1.5.6",
"glicko2": "^1.2.1", "glicko2": "^1.2.1",
"jose": "^6.2.2", "jose": "^6.2.2",
"next": "^16.2.4", "next": "^16.2.1",
"openskill": "^4.1.1", "openskill": "^4.1.1",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pg": "^8.20.0", "pg": "^8.20.0",
"prisma": "^7.8.0", "prisma": "^7.6.0",
"react": "^19.2.5", "react": "^19.2.4",
"react-dom": "^19.2.5", "react-dom": "^19.2.4",
"react-hook-form": "^7.74.0", "react-hook-form": "^7.72.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@cucumber/cucumber": "^12.8.2", "@cucumber/cucumber": "^12.8.2",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/bun": "^1.3.13", "@types/bun": "^1.3.11",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"@types/node": "^20.19.39", "@types/node": "^20",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@@ -80,12 +80,12 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"cucumber-pretty": "^6.0.1", "cucumber-pretty": "^6.0.1",
"eslint": "^8.57.1", "eslint": "^8.57.0",
"eslint-config-next": "^16.2.4", "eslint-config-next": "^16.2.1",
"jsdom": "^29.1.0", "jsdom": "^29.0.1",
"tailwindcss": "^4.2.4", "tailwindcss": "^4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5",
"vitest": "^4.1.5" "vitest": "^4.1.2"
} }
} }
+1 -1
View File
@@ -258,7 +258,7 @@ export default function AdminPlayersPage() {
</div> </div>
{/* Player Table */} {/* Player Table */}
<div className="bg-white shadow rounded-lg overflow-x-auto"> <div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
+1 -21
View File
@@ -73,7 +73,7 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
const [team1Score, setTeam1Score] = useState("") const [team1Score, setTeam1Score] = useState("")
const [team2Score, setTeam2Score] = useState("") const [team2Score, setTeam2Score] = useState("")
// Parse params and validate tournamentId, check for matchup query param // Parse params and validate tournamentId
useEffect(() => { useEffect(() => {
async function parseParams() { async function parseParams() {
const { id } = await params const { id } = await params
@@ -87,26 +87,6 @@ export default function TournamentEntryPage({ params }: { params: Promise<{ id:
parseParams() parseParams()
}, [params, router]) }, [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 // Load tournament, schedule, and matches
useEffect(() => { useEffect(() => {
if (tournamentId) { if (tournamentId) {
@@ -2,8 +2,6 @@ import { prisma } from "@/lib/prisma"
import Navigation from "@/components/Navigation" import Navigation from "@/components/Navigation"
import Link from "next/link" import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { ScheduleGenerator } from "@/components/ScheduleGenerator"
import { ScheduleDisplay } from "@/components/ScheduleDisplay"
interface PageProps { interface PageProps {
params: Promise<{ params: Promise<{
@@ -11,9 +9,7 @@ interface PageProps {
}> }>
} }
// Force dynamic rendering and revalidate on each request
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export const revalidate = 0
export default async function TournamentSchedulePage({ params }: PageProps) { export default async function TournamentSchedulePage({ params }: PageProps) {
const { id } = await params const { id } = await params
@@ -23,7 +19,6 @@ 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: {
@@ -32,35 +27,13 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
player: true, 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) { if (!tournament) {
notFound() notFound()
} }
const teamCount = tournament.participants.length
const existingRounds = tournament.rounds.length
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Navigation /> <Navigation />
@@ -80,30 +53,19 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
Schedule - {tournament.name} Schedule - {tournament.name}
</h1> </h1>
<div className="bg-white shadow rounded-lg p-6 mb-6"> <div className="bg-white shadow rounded-lg p-6">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-900"> <h2 className="text-xl font-bold text-gray-900">
Tournament Schedule Tournament Schedule
</h2> </h2>
<button className="bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-green-700">
Generate Schedule
</button>
</div> </div>
<div id="schedule-display"> <p className="text-gray-500">
{existingRounds > 0 ? ( No schedule has been generated yet. Click "Generate Schedule" to create round matchups.
<ScheduleDisplay rounds={tournament.rounds} tournamentId={tournamentId} /> </p>
) : (
<p className="text-gray-500 mb-6">
No schedule has been generated yet. Click "Generate Schedule" to create round matchups.
</p>
)}
</div>
<div className="mt-6 pt-6 border-t border-gray-200">
<ScheduleGenerator
tournamentId={tournamentId}
teamCount={teamCount}
existingRounds={existingRounds}
/>
</div>
</div> </div>
</div> </div>
</main> </main>
-37
View File
@@ -1,37 +0,0 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(request: Request) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: "Email is required" },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) {
return NextResponse.json(
{ error: "If an account exists with that email, a password reset link will be sent" },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
message: "If an account exists with that email, a password reset link will be sent"
});
} catch (error: unknown) {
console.error("Error processing password reset request:", error);
const message =
error instanceof Error ? error.message : "Failed to process password reset request";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+5 -35
View File
@@ -1,5 +1,4 @@
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";
@@ -79,15 +78,11 @@ 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 }
@@ -95,7 +90,6 @@ 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" },
@@ -104,7 +98,6 @@ 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: {
@@ -118,17 +111,14 @@ 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 },
@@ -145,8 +135,6 @@ 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(
@@ -159,24 +147,20 @@ 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 are required to generate a schedule" }, { error: "At least 2 teams (4 players) are required to generate a schedule" },
{ status: 400 } { status: 400 }
); );
} }
// Calculate expected rounds // Calculate number of rounds needed
const numRounds = expectedRounds(teamCount); const numRounds = expectedRounds(teamCount);
console.log(`[Schedule API] Expected rounds: ${numRounds}`);
if (teamDurability === "permanent") { if (teamDurability === "permanent") {
// ============================================ // ============================================
@@ -202,14 +186,11 @@ 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) =>
console.log(`[Schedule API] Creating round ${round.roundNumber} with ${round.matchups.length} matchups`); prisma.tournamentRound.create({
return prisma.tournamentRound.create({
data: { data: {
eventId: tournamentId, eventId: tournamentId,
roundNumber: round.roundNumber, roundNumber: round.roundNumber,
@@ -237,18 +218,9 @@ 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,
@@ -323,8 +295,6 @@ 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,
-16
View File
@@ -15,22 +15,6 @@ export default function PasswordResetPage() {
setError("") setError("")
try { try {
const response = await fetch("/api/auth/password-reset", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
})
const data = await response.json()
if (!response.ok) {
setError(data.error || "Failed to send reset link")
setLoading(false)
return
}
setSent(true) setSent(true)
} catch (err) { } catch (err) {
console.error("Password reset error:", err) console.error("Password reset error:", err)
+1 -1
View File
@@ -22,7 +22,7 @@ export default function RootLayout({
lang="en" lang="en"
className={`${inter.variable} h-full antialiased`} className={`${inter.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col overflow-x-hidden"> <body className="min-h-full flex flex-col">
<SessionProvider> <SessionProvider>
{children} {children}
<Footer /> <Footer />
+3 -4
View File
@@ -128,10 +128,9 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
].filter(Boolean).join(" + ") ].filter(Boolean).join(" + ")
return ( return (
<Link <div
href={`/matches/${match.id}`}
key={match.id} key={match.id}
className="block border border-gray-200 rounded-lg p-4 hover:bg-gray-50 cursor-pointer" className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
> >
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex-1"> <div className="flex-1">
@@ -148,7 +147,7 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
</span> </span>
</div> </div>
</div> </div>
</Link> </div>
) )
})} })}
</div> </div>
+3 -3
View File
@@ -59,7 +59,7 @@ export default function RankingsClient({ players }: { players: PlayerWithRatings
{/* Elo Rating Tab */} {/* Elo Rating Tab */}
{activeTab === "elo" && ( {activeTab === "elo" && (
<div className="bg-white shadow overflow-x-auto sm:rounded-lg"> <div className="bg-white shadow overflow-hidden sm:rounded-lg">
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b"> <h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
Elo Rating Rankings Elo Rating Rankings
</h2> </h2>
@@ -117,7 +117,7 @@ export default function RankingsClient({ players }: { players: PlayerWithRatings
{/* OpenSkill Rating Tab */} {/* OpenSkill Rating Tab */}
{activeTab === "openskill" && ( {activeTab === "openskill" && (
<div className="bg-white shadow overflow-x-auto sm:rounded-lg"> <div className="bg-white shadow overflow-hidden sm:rounded-lg">
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b"> <h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
OpenSkill Rating Rankings OpenSkill Rating Rankings
</h2> </h2>
@@ -178,7 +178,7 @@ export default function RankingsClient({ players }: { players: PlayerWithRatings
{/* Glicko2 Rating Tab */} {/* Glicko2 Rating Tab */}
{activeTab === "glicko2" && ( {activeTab === "glicko2" && (
<div className="bg-white shadow overflow-x-auto sm:rounded-lg"> <div className="bg-white shadow overflow-hidden sm:rounded-lg">
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b"> <h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
Glicko2 Rating Rankings Glicko2 Rating Rankings
</h2> </h2>
+2 -4
View File
@@ -1,7 +1,6 @@
"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 {
@@ -41,7 +40,6 @@ 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;
@@ -172,9 +170,9 @@ export default function MatchEditor({
isCasual: false, isCasual: false,
}) })
// Refresh the page to show updated matches // Reload the page to show updated matches
setTimeout(() => { setTimeout(() => {
router.refresh() window.location.reload()
}, 1000) }, 1000)
} catch { } catch {
setError("An error occurred. Please try again.") setError("An error occurred. Please try again.")
+5 -5
View File
@@ -50,14 +50,14 @@ export default function Navigation() {
<nav className="bg-white shadow-sm"> <nav className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16"> <div className="flex justify-between h-16">
<div className="flex items-center min-w-0 overflow-hidden"> <div className="flex items-center">
<Link <Link
href="/" href="/wordmark-redirect"
className="text-xl font-bold text-gray-900 no-underline flex-shrink-0" className="text-xl font-bold text-gray-900 no-underline"
> >
EuchreCamp EuchreCamp
</Link> </Link>
<div className="hidden md:ml-6 md:flex md:space-x-8 min-w-0 overflow-hidden"> <div className="hidden md:ml-6 md:flex md:space-x-8">
<Link <Link
href="/rankings" href="/rankings"
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
@@ -110,7 +110,7 @@ export default function Navigation() {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center min-w-0 overflow-hidden"> <div className="flex items-center">
{loading ? ( {loading ? (
<div className="text-gray-500">Loading...</div> <div className="text-gray-500">Loading...</div>
) : session ? ( ) : session ? (
+1 -3
View File
@@ -1,10 +1,8 @@
"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 () => {
@@ -35,7 +33,7 @@ export function RecalculateEloButton() {
if (data.success) { if (data.success) {
alert(`Recalculation completed: ${JSON.stringify(data.data)}`) alert(`Recalculation completed: ${JSON.stringify(data.data)}`)
router.refresh() window.location.reload()
} else { } else {
alert(`Error: ${data.error}`) alert(`Error: ${data.error}`)
} }
-90
View File
@@ -1,90 +0,0 @@
"use client"
import Link from "next/link"
interface Player {
id: number
name: string
}
interface BracketMatchup {
id: number
player1P1: Player | null
player1P2: Player | null
player2P1: Player | null
player2P2: Player | null
match: { id: number } | null
bracketPosition: number | null
status: string
}
interface TournamentRound {
id: number
roundNumber: number
status: string
bracketMatchups: BracketMatchup[]
}
interface ScheduleDisplayProps {
rounds: TournamentRound[]
tournamentId: number
}
export function ScheduleDisplay({ rounds, tournamentId }: ScheduleDisplayProps) {
return (
<div className="space-y-6">
{rounds.map((round) => (
<div key={round.id} className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Round {round.roundNumber}</h3>
<span className={`text-sm px-2 py-1 rounded ${
round.status === 'completed' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}>
{round.status}
</span>
</div>
<div className="space-y-2">
{round.bracketMatchups.map((matchup) => {
const content = (
<div className="p-3 border border-gray-200 rounded hover:border-green-500 transition-colors">
<div className="flex justify-between items-center">
<div className="flex-1">
<p className="text-sm text-gray-500">
Match {matchup.bracketPosition || matchup.id}
</p>
<p className="font-medium">
{matchup.player1P1?.name || 'TBD'} & {matchup.player1P2?.name || 'TBD'}
</p>
<p className="text-sm text-gray-500">vs</p>
<p className="font-medium">
{matchup.player2P1?.name || 'TBD'} & {matchup.player2P2?.name || 'TBD'}
</p>
</div>
<div className="text-right">
{matchup.match ? (
<span className="text-sm text-green-600">Completed</span>
) : (
<span className="text-sm text-gray-400">Pending</span>
)}
</div>
</div>
</div>
)
return (
<Link
key={matchup.id}
href={`/admin/tournaments/${tournamentId}/entry?matchup=${matchup.id}`}
className="block hover:bg-gray-100 rounded-md transition-colors"
data-testid="matchup"
>
{content}
</Link>
)
})}
</div>
</div>
))}
</div>
)
}
+5 -5
View File
@@ -1,7 +1,6 @@
"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
@@ -10,7 +9,6 @@ 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<{
@@ -50,8 +48,10 @@ export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: S
}) })
setIsGenerating(false) setIsGenerating(false)
// Re-fetch the schedule data from the server // Reload to show the schedule
router.refresh() setTimeout(() => {
window.location.reload()
}, 1500)
} catch { } catch {
setError("An error occurred. Please try again.") setError("An error occurred. Please try again.")
setIsGenerating(false) setIsGenerating(false)
@@ -86,7 +86,7 @@ export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: S
return return
} }
router.refresh() window.location.reload()
} catch { } catch {
setError("An error occurred. Please try again.") setError("An error occurred. Please try again.")
setIsGenerating(false) setIsGenerating(false)