52 Commits

Author SHA1 Message Date
david b2fcf0fae7 chore: remove .env.production from repository and add to .gitignore
Pull Request / unit-tests (pull_request) Successful in 1m5s
Pull Request / analyze-bump-type (pull_request) Has been cancelled
Pull Request / e2e-tests (pull_request) Has been cancelled
2026-04-26 16:42:35 -07:00
david 0f53f8a08f chore: add new route pages and environment configuration
Pull Request / unit-tests (pull_request) Successful in 1m2s
Pull Request / analyze-bump-type (pull_request) Has been cancelled
Pull Request / e2e-tests (pull_request) Has been cancelled
2026-04-26 16:39:14 -07:00
david 36e273e2a1 test: update test files and documentation 2026-04-26 16:39:14 -07:00
david e61e020d9d fix: update authentication and navigation components 2026-04-26 16:39:14 -07:00
david 4120cc9a8e fix: update Prisma client configuration and cleanup script 2026-04-26 16:39:14 -07:00
david c4e88d3658 ci: add E2E test job to PR workflow and update test scripts 2026-04-26 16:39:14 -07:00
david 5ab6ece5ef test: update Cucumber step definitions for better error handling and debugging 2026-04-26 16:39:14 -07:00
david b3907d046d test: update Cucumber configuration and support files 2026-04-26 16:39:14 -07:00
david 9026ac7fe3 fix: update registration redirect and wordmark navigation
- Simplify registration redirect after session refresh
- Update wordmark to use dynamic href based on session/role
- Add anchor tag for wordmark to avoid Next.js Link issues
2026-04-26 16:39:14 -07:00
david 73f5905990 fix: add browser relaunch logic to Cucumber hooks
- Add error handling for browser context creation
- Auto-relaunch browser if context creation fails
- Add better error logging
2026-04-26 16:39:14 -07:00
david 5519ed1de0 fix: add database safety check to Cucumber hooks
- Prevents tests from running against production database
- Uses same logic as Playwright global setup
- Ensures test data isolation
2026-04-26 16:39:13 -07:00
Gitea Actions f51f3caa34 chore: bump version to v0.1.6 2026-04-26 16:39:13 -07:00
david 933f2cf808 fix: update Cucumber config and hooks to properly load test infrastructure
- Add support files to require config for proper hook loading
- Remove database cleanup from hooks (browser-only testing)
- Fix World type issues in hooks
- Cucumber tests now run successfully
2026-04-26 16:39:13 -07:00
david a1d754a5fb feat: add feature summary documentation
- Add FEATURE_SUMMARY.md with coverage overview
- Fix TypeScript error in World class
- Document all feature files and scenarios
2026-04-26 16:39:13 -07:00
david 78ff75b941 docs: update README with feature files and issue tracking 2026-04-26 16:39:13 -07:00
david d5dc170417 feat: add tournament schedule feature test (issue #7)
- Add tournament-schedule.feature with 4 scenarios
- Add step definitions for tournament schedule navigation
- Tests viewing schedule, generating round-robin, bye rounds, and matchup navigation
- Note: Some @wip scenarios require data setup (tournament creation, team addition)
2026-04-26 16:39:13 -07:00
david 1093449e46 feat: add player schedule feature test (issue #9)
- Add player-schedule.feature with 3 scenarios
- Add step definitions for schedule navigation
- Tests empty schedule, upcoming matches, and match details navigation
- Note: Some @wip scenarios require data setup (tournament, participants)
2026-04-26 16:39:13 -07:00
david 54aa22d1a3 feat: add password reset feature test (issue #10)
- Add password-reset.feature with 4 scenarios
- Add step definition for password reset page assertion
- Tests access page, valid email request, invalid email, and empty field
2026-04-26 16:39:13 -07:00
david 22f7b79a3d feat: add wordmark navigation feature test (issue #24)
- Add wordmark-navigation.feature with 3 scenarios
- Add step definitions for clicking wordmark and page assertions
- Test unauthenticated, player, and admin navigation paths
2026-04-26 16:39:13 -07:00
david 999cdd821c docs: add README for Cucumber test framework 2026-04-26 16:39:13 -07:00
david 496541b144 feat: add cucumber gherkin-style E2E test framework
- Install @cucumber/cucumber and cucumber-pretty dependencies
- Create Cucumber configuration with tsx loader for TypeScript support
- Add step definitions for common navigation, form interactions, and assertions
- Add authentication step definitions for login/logout flows
- Create feature files for user registration and authentication
- Support multiple auth contexts (player, tournament admin, club admin)
- Configure package.json scripts for running Cucumber tests

Key features:
- Gherkin syntax (Given/When/Then) for behavior-driven testing
- Browser-only interactions (no direct database access)
- Dev site testing (tests run against running dev server)
- Happy path focus for acceptance testing
- Integration with existing TypeScript project
2026-04-26 16:39:13 -07:00
Gitea Actions d21e14c3b0 chore: bump version to v0.1.5 2026-04-26 16:39:13 -07:00
david 8f0d5c4cf5 fix: update Match interface in entry page to use correct player property names 2026-04-04 00:31:18 -07:00
david 15661356d2 fix: update AdminMatchesPage to use correct player property names (player1P1, player1P2, etc.) 2026-04-04 00:29:18 -07:00
david 603cc238fa feat: implement variable team matchups with partner rotation
- Add manual team entry for permanent teams in Matchups tab
- Rename 'Teams' tab to 'Matchups' for clarity
- Implement partner rotation strategies (minimize_repeat, maximize_even, elo_based)
- Track partnerships across rounds to minimize repeat pairings
- Fix unit tests for team configuration and ELO calculations
- Add E2E test for 9+ participant tournaments with variable matchups

Key changes:
- TeamsSection.tsx: Added router.refresh(), manual team entry, and matchup display
- schedule-generator.ts: Enhanced generateVariableRoundRobin to track partnerships
- team-generator.ts: Fixed partnership frequency tracking across rounds
- API routes: Updated to support variable team durability with rotation strategies
2026-04-04 00:24:57 -07:00
david 63ef1d124c refactor(tests): update remaining test files for ephemeral team model 2026-04-03 22:00:01 -07:00
david c222e55a52 refactor(tests): update test files to use new player field names 2026-04-03 21:04:06 -07:00
david e0c986f594 refactor(ui): update components to use new player field names 2026-04-03 21:03:57 -07:00
david 1f7d589698 refactor(api): update all API routes to use new player field names 2026-04-03 21:03:51 -07:00
david e5f679e54c refactor(lib): update rating utilities to use new player field names 2026-04-03 21:03:44 -07:00
david 803b79f03c refactor(lib): update schedule and team generators to use player pairings 2026-04-03 21:03:31 -07:00
david aa98600147 refactor(schema): update Match model to use player fields instead of team fields 2026-04-03 21:03:23 -07:00
david ad7724cda6 refactor(db): remove Team model and add player fields to BracketMatchup 2026-04-03 21:03:08 -07:00
david ada163f538 chore: add .env.dev to gitignore 2026-04-03 19:57:12 -07:00
david 4d685558aa fix: support partial updates in tournament PUT endpoint
- Change PUT endpoint to only include fields present in the request
- Add support for team configuration fields (teamDurability, partnerRotation, allowByes)
- Fix contradictory test that expected resetting allowTies when not provided
- When a field is not in the request, it is preserved (not modified)

Fixes issue where Save Configuration button would fail with
'Tournament name is required' error.
2026-04-03 19:56:58 -07:00
david 07283d0334 chore: add .env.development to gitignore 2026-04-03 19:28:26 -07:00
david dca35ec0bf feat: add tournamentType field to Event model
Add tournamentType field with default value 'individual':
- Supports 'individual' and 'team' tournament types
- Creates migration for database schema update
2026-04-03 19:27:58 -07:00
david c3b0466092 feat: add tournament type and team support to tournament APIs
Update tournament APIs to support:
- tournamentType field (individual/team)
- Team creation for team tournaments
- Even number validation for team tournaments
- Automatic pairing of consecutive players into teams
2026-04-03 19:27:31 -07:00
david a75d7d3cc6 fix: make player search case-insensitive
Add 'mode: insensitive' to Prisma query for player search:
- Allows searching by partial name regardless of case
- Improves user experience when searching for players
2026-04-03 19:27:19 -07:00
david 37eb1f8e21 fix: check response.ok before parsing JSON in fetch calls
Fix JSON parsing errors when server returns non-JSON responses:
- Check response.ok before calling response.json()
- Add fallback error messages using status text
- Apply fix to all fetch calls across 12 components

This prevents 'JSON.parse: unexpected character' errors when
server returns HTML error pages or other non-JSON responses.
2026-04-03 19:27:10 -07:00
david 9b15d7e61f test: add unit tests for team generation algorithms
Add comprehensive tests for team generation:
- Test random pairing strategy
- Test ELO-based pairing strategy
- Test even matches strategy
- Test minimize repeat partnerships
- Test bye player handling
- Test team balance calculation
- Test partnership frequency tracking

All 19 tests pass.

Refs #22
2026-04-03 19:26:59 -07:00
david 322ab2a5fa feat: integrate TeamsSection component into tournament detail page
Update tournament detail page to use new TeamsSection component:
- Import TeamsSection component
- Replace static teams display with interactive configuration
- Pass tournament ID, teams, and participants to component

Refs #22
2026-04-03 19:26:52 -07:00
david c4d2130d5b feat: add TeamsSection component for team configuration UI
Add interactive team configuration panel:
- Team durability selection (permanent, variable, per_round)
- Partner rotation strategy selection
- Allow byes configuration
- Generate Teams button with participant count
- Delete All Teams functionality
- Available participants display with ELO ratings

Refs #22
2026-04-03 19:26:44 -07:00
david 051b729451 feat: add team generation API endpoints
Add API endpoints for team management:
- POST /api/tournaments/[id]/teams/generate: Generate teams based on configuration
- DELETE /api/tournaments/[id]/teams: Delete all teams
- GET /api/tournaments/[id]/teams: Fetch teams and configuration

Supports multiple generation strategies:
- Random pairing
- ELO-based pairing
- Even matches
- Minimize repeat partnerships

Refs #22
2026-04-03 19:26:36 -07:00
david 443a0f460e feat: add team generator library with multiple strategies
Implement team generation algorithms for tournament partnerships:
- Random pairing (Fisher-Yates shuffle)
- ELO-based pairing (strongest + weakest)
- Even matches (balance competitive levels)
- Minimize repeat partnerships
- Support for byes with odd participant counts
- Team balance calculation utilities

Refs #22
2026-04-03 19:26:28 -07:00
david 7367fce4a6 feat: add team configuration fields to Event model
Add fields for team durability, partner rotation, and configuration:
- teamDurability: permanent, variable, or per_round
- partnerRotation: none, minimize_repeat, maximize_even, elo_based
- allowByes: handle odd participant counts
- teamConfiguration: JSON for additional options
- maxRosterChanges: limit roster changes per player
- requireAdminVerify: for match score verification

Refs #22
2026-04-03 19:26:11 -07:00
david ab50ae0bdf fix: use bun.lock instead of bun.lockb in Dockerfile
Bun v1.2+ switched from binary lockfile (bun.lockb) to text-based
bun.lock format. Update the Dockerfile COPY to match.
2026-04-02 03:06:22 -07:00
david 90fb0f223e test: add acceptance tests for schedule tab
Playwright tests covering: schedule link visibility, empty state,
schedule generation, round/matchup display, and API response format.
Closes #7.
2026-04-02 01:03:07 -07:00
david a49c8e01ac feat: convert tournament tabs to functional navigation links
Replace non-functional tab buttons with Link components for Schedule
and Results tabs. Disable unimplemented tabs (Participants, Teams,
Analytics) as styled spans with cursor-not-allowed.
2026-04-02 01:02:02 -07:00
david 19402be375 feat: add tournament schedule page with generator component
Schedule page displays round-robin rounds with matchups, team names,
status badges, and links to result entry. Generator component provides
generate/delete buttons with success/error feedback.
2026-04-02 01:01:04 -07:00
david 84afa88ca4 feat: add schedule API endpoints
GET returns rounds with matchups for a tournament.
POST generates a round-robin schedule from registered teams.
DELETE removes all rounds and matchups.

All endpoints enforce tournament admin permissions via canManageTournament.
2026-04-02 00:59:32 -07:00
david df856c62df feat: add round-robin schedule generator
Implement circle-method algorithm for generating round-robin tournament
schedules. Handles both even and odd team counts with bye rounds.

Includes unit tests for algorithm correctness, input validation, and
expected round/matchup calculations.
2026-04-02 00:57:49 -07:00
50 changed files with 10491 additions and 2490 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
-71
View File
@@ -1,74 +1,3 @@
## [0.1.15] - 2026-05-02
### Patch Changes
- Merge branch 'main' of https://git.notsosm.art/david/euchre_camp
- feat: add view-as-role feature for site admins (#15)
## [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
### Patch Changes
- test: migrate rankings and home-page tests from Playwright to Cucumber
## [0.1.7] - 2026-04-27
### Patch Changes
- fix: replace waitForLoadState('networkidle') with domcontentloaded in all E2E tests
- feat: Implement tournament schedule tab and fix E2E tests (#27)
## [0.1.6] - 2026-04-26 ## [0.1.6] - 2026-04-26
### 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
+2 -2
View File
@@ -56,7 +56,7 @@ test.describe.serial('Account Lifecycle Acceptance Test', () => {
await page.goto('/auth/register'); await page.goto('/auth/register');
// Wait for JavaScript to be ready // Wait for JavaScript to be ready
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
// Wait for the form to be visible and the submit button to be enabled // Wait for the form to be visible and the submit button to be enabled
await page.waitForSelector('form'); await page.waitForSelector('form');
@@ -105,7 +105,7 @@ test.describe.serial('Account Lifecycle Acceptance Test', () => {
// Reload to ensure session is loaded from cookies // Reload to ensure session is loaded from cookies
await page.reload(); await page.reload();
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
// Wait for logout button to appear // Wait for logout button to appear
await page.waitForSelector('text=Sign out', { timeout: 10000 }); await page.waitForSelector('text=Sign out', { timeout: 10000 });
@@ -1,36 +0,0 @@
Feature: Club Admin Dashboard
As a club admin
I want to view club-wide statistics and manage club operations
So that I can effectively oversee the club
@happy-path @admin @issue-11
Scenario: Club admin views dashboard with statistics
Given I am logged in as a club admin
When I go to the admin dashboard
Then I should see "Admin Dashboard"
And I should see total player count
And I should see active tournament count
@happy-path @admin @issue-11
Scenario: Club admin views recent activity feed
Given I am logged in as a club admin
And there are recent activities in the system
When I go to the admin dashboard
Then I should see the activity feed section
And I should see recent player registrations
@happy-path @admin @issue-11
Scenario: Club admin searches player directory
Given I am logged in as a club admin
And there are multiple players in the system
When I go to the player management page
And I search for "Player 1"
Then I should see search results
@happy-path @admin @issue-11
Scenario: Club admin updates club settings
Given I am logged in as a club admin
When I go to the club settings page
And I update the club name
And I save the settings
Then the settings should be saved successfully
-27
View File
@@ -1,27 +0,0 @@
Feature: Home Page
As a visitor
I want to see the home page
So that I can learn about the club and view player rankings
@happy-path @public @home
Scenario: Home page displays Top 10 Players
Given I am on the home page
Then I should see "Top 10 Players"
And I should see a rankings table
@happy-path @public @home
Scenario: Home page displays club information
Given I am on the home page
Then I should see "Club Information"
And I should see "Club President"
@happy-path @public @home
Scenario: Home page displays most recent tournament
Given I am on the home page
Then I should see "Most Recent Tournament"
@happy-path @public @home
Scenario: Home page has sign in and create account links
Given I am on the home page
Then I should see "Sign In"
And I should see "Create Account"
@@ -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
-23
View File
@@ -1,23 +0,0 @@
Feature: Rankings Page
As a visitor
I want to view player rankings
So that I can see top players and their statistics
@happy-path @public @rankings
Scenario: Rankings page loads and displays rankings table
When I go to the rankings page
Then I should see "Player Rankings" in the page heading
And I should see a rankings table
@happy-path @public @rankings
Scenario: Rankings table displays player columns
When I go to the rankings page
Then I should see a rankings table with columns
And the table should have column headers
@happy-path @public @rankings
Scenario: Rankings page is publicly accessible (no login required)
Given I am not logged in
When I go to the rankings page
Then I should be on the rankings page
And I should see the rankings table
@@ -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
@@ -1,46 +0,0 @@
Feature: View As Role
As a site admin
I want to temporarily view the site as a player or club admin
So that I can understand and improve the experience for each role
@happy-path @admin-features @issue-15
Scenario: Site admin sees role switcher in navigation
Given I am logged in as a site admin
When I view the navigation
Then I should see the role switcher dropdown
Then the role switcher should default to "Viewing as Site Admin"
@happy-path @admin-features @issue-15
Scenario: Site admin switches to player view
Given I am logged in as a site admin
When I select "View as Player" from the role switcher
Then I should see the player navigation links
And I should not see the "Admin" link
And I should not see the "Users" link
And I should see a banner indicating I am viewing as "Player"
@happy-path @admin-features @issue-15
Scenario: Site admin switches to tournament admin view
Given I am logged in as a site admin
When I select "View as Tournament Admin" from the role switcher
Then I should see the "Tournaments" link
And I should not see the "Admin" link
And I should not see the "Users" link
And I should see a banner indicating I am viewing as "Tournament Admin"
@happy-path @admin-features @issue-15
Scenario: Site admin switches to club admin view
Given I am logged in as a site admin
When I select "View as Club Admin" from the role switcher
Then I should see the "Admin" link
And I should see the "Users" link
And I should see a banner indicating I am viewing as "Club Admin"
@happy-path @admin-features @issue-15
Scenario: Site admin resets to site admin view
Given I am logged in as a site admin
When I select "View as Player" from the role switcher
And I click the "Reset to Site Admin" button
Then the role switcher should default to "Viewing as Site Admin"
And I should see the "Admin" link
And I should not see the viewing as banner
+39 -303
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,89 +133,9 @@ 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}`);
}); });
/**
* Precondition: I am logged in as a site admin
* Creates a new user and assigns site_admin role via Prisma
*/
Given('I am logged in as a site admin', async function () {
console.log('🌍 Creating and logging in as a site admin...');
const credentials = generateTestCredentials();
world.user = credentials;
await world.page.goto(`${world.baseURL}/auth/register`);
await world.page.waitForLoadState('domcontentloaded');
await world.page.fill('input[name="name"]', credentials.name);
await world.page.fill('input[name="email"]', credentials.email);
await world.page.fill('input[name="password"]', credentials.password);
await world.page.click('button[type="submit"]');
await world.page.waitForURL(/\/players\/\d+\/profile/, { timeout: 15000 });
const currentUrl = world.page.url();
const match = currentUrl.match(/\/players\/(\d+)\/profile/);
if (match) {
const playerId = match[1];
world.playerId = playerId;
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;
await prisma.user.update({
where: { id: userId },
data: { role: 'site_admin' }
});
console.log(`🌍 Assigned site_admin role to user: ${userId}`);
// Navigate to home page to trigger Navigation re-mount with new role
await world.page.goto(`${world.baseURL}/`);
await world.page.waitForLoadState('networkidle');
await world.page.waitForTimeout(1000);
}
}
console.log(`🌍 Site admin created: ${credentials.email}`);
});
/** /**
* Precondition: I am logged in as a club admin * Precondition: I am logged in as a club admin
* Uses a pre-existing admin user from the database * Uses a pre-existing admin user from the database
@@ -251,7 +177,7 @@ When('I register with valid credentials', async function () {
world.user = credentials; world.user = credentials;
await world.page.goto(`${world.baseURL}/auth/register`); await world.page.goto(`${world.baseURL}/auth/register`);
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('networkidle');
await world.page.fill('input[name="name"]', credentials.name); await world.page.fill('input[name="name"]', credentials.name);
await world.page.fill('input[name="email"]', credentials.email); await world.page.fill('input[name="email"]', credentials.email);
@@ -268,7 +194,7 @@ When('I register with duplicate email', async function () {
} }
await world.page.goto(`${world.baseURL}/auth/register`); await world.page.goto(`${world.baseURL}/auth/register`);
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('networkidle');
await world.page.fill('input[name="name"]', world.user.name); await world.page.fill('input[name="name"]', world.user.name);
await world.page.fill('input[name="email"]', world.user.email); await world.page.fill('input[name="email"]', world.user.email);
@@ -294,7 +220,7 @@ When('I register with weak password', async function () {
credentials.password = 'weak'; // Too short credentials.password = 'weak'; // Too short
await world.page.goto(`${world.baseURL}/auth/register`); await world.page.goto(`${world.baseURL}/auth/register`);
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('networkidle');
await world.page.fill('input[name="name"]', credentials.name); await world.page.fill('input[name="name"]', credentials.name);
await world.page.fill('input[name="email"]', credentials.email); await world.page.fill('input[name="email"]', credentials.email);
@@ -314,7 +240,7 @@ When('I log in with valid credentials', async function () {
} }
await world.page.goto(`${world.baseURL}/auth/login`); await world.page.goto(`${world.baseURL}/auth/login`);
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('networkidle');
await world.page.fill('input[name="email"]', world.user.email); await world.page.fill('input[name="email"]', world.user.email);
await world.page.fill('input[name="password"]', world.user.password); await world.page.fill('input[name="password"]', world.user.password);
@@ -326,7 +252,7 @@ When('I log in with valid credentials', async function () {
When('I log in with invalid credentials', async function () { When('I log in with invalid credentials', async function () {
await world.page.goto(`${world.baseURL}/auth/login`); await world.page.goto(`${world.baseURL}/auth/login`);
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('networkidle');
await world.page.fill('input[name="email"]', 'nonexistent@example.com'); await world.page.fill('input[name="email"]', 'nonexistent@example.com');
await world.page.fill('input[name="password"]', 'wrongpassword'); await world.page.fill('input[name="password"]', 'wrongpassword');
@@ -411,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}`);
}); });
/** /**
@@ -482,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,
}, },
}); });
} }
@@ -525,137 +377,21 @@ 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('networkidle');
// 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 () {
// Create test activities using the activity logger
const prisma = await world.getPrisma()
// Use timestamp to ensure unique names
const timestamp = Date.now()
// Create a test player first
const player = await prisma.player.create({
data: {
name: `Test Activity Player ${timestamp}`,
normalizedName: `test activity player ${timestamp}`,
currentElo: 1000,
gamesPlayed: 0,
wins: 0,
losses: 0,
},
})
// Create an activity
await (prisma as any).activity.create({
data: {
type: 'player_registration',
description: `Test Activity Player ${timestamp} registered`,
playerId: player.id,
},
})
console.log('🌍 Created test activity for player:', player.name)
})
Given('there are multiple players in the system', async function () {
const prisma = await world.getPrisma()
// Use timestamp to ensure unique names
const timestamp = Date.now()
// Create multiple test players
for (let i = 1; i <= 5; i++) {
await prisma.player.create({
data: {
name: `Test Player ${i} ${timestamp}`,
normalizedName: `test player ${i} ${timestamp}`,
currentElo: 1000 + i * 10,
gamesPlayed: 0,
wins: 0,
losses: 0,
},
})
}
console.log('🌍 Created 5 test players')
})
+23 -296
View File
@@ -29,12 +29,6 @@ Given('I am on the login page', async function () {
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('domcontentloaded');
}); });
Given('I am on the password reset page', async function () {
console.log('🌍 Navigating to password reset page');
await world.page.goto(`${world.baseURL}/auth/password-reset`);
await world.page.waitForLoadState('domcontentloaded');
});
Given('I am on the {string} page', async function (pageName: string) { Given('I am on the {string} page', async function (pageName: string) {
const pageUrls: Record<string, string> = { const pageUrls: Record<string, string> = {
'home': '/', 'home': '/',
@@ -109,14 +103,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'));
}); });
/** /**
@@ -173,7 +161,7 @@ When('I click the {string} button', async function (buttonText: string) {
console.log(`🌍 URL did not change immediately after click`); console.log(`🌍 URL did not change immediately after click`);
// Wait for potential network activity to settle // Wait for potential network activity to settle
try { try {
await world.page.waitForLoadState('domcontentloaded', { timeout: 3000 }); await world.page.waitForLoadState('networkidle', { timeout: 3000 });
} catch { } catch {
console.log(`🌍 Network idle not reached, continuing anyway`); console.log(`🌍 Network idle not reached, continuing anyway`);
} }
@@ -186,18 +174,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) {
@@ -407,7 +405,7 @@ Then('I should see the {string} button', async function (buttonText: string) {
}); });
Then('I should be redirected to {string}', async function (path: string) { Then('I should be redirected to {string}', async function (path: string) {
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('networkidle');
const currentUrl = world.page.url(); const currentUrl = world.page.url();
console.log(`🌍 Checking redirect to: ${path}`); console.log(`🌍 Checking redirect to: ${path}`);
@@ -438,7 +436,7 @@ When('I wait for {int} seconds', async function (seconds: number) {
}); });
When('I wait for the page to load', async function () { When('I wait for the page to load', async function () {
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('networkidle');
}); });
/** /**
@@ -451,7 +449,7 @@ Then('the URL should contain {string}', async function (expectedPath: string) {
}); });
Then('I should be redirected to the login page', async function () { Then('I should be redirected to the login page', async function () {
await world.page.waitForLoadState('domcontentloaded'); await world.page.waitForLoadState('networkidle');
const currentUrl = world.page.url(); const currentUrl = world.page.url();
console.log(`🌍 Checking redirect to login page. Current URL: ${currentUrl}`); console.log(`🌍 Checking redirect to login page. Current URL: ${currentUrl}`);
@@ -477,274 +475,3 @@ Then('I should see {string} error', async function (errorMessage: string) {
expect(content).toMatch(new RegExp(errorMessage, 'i')); expect(content).toMatch(new RegExp(errorMessage, 'i'));
console.log(`🌍 Verified error message: ${errorMessage}`); console.log(`🌍 Verified error message: ${errorMessage}`);
}); });
// Admin Dashboard Steps
When('I go to the admin dashboard', async function () {
console.log('🌍 Going to admin dashboard');
await world.page.goto(`${world.baseURL}/admin`);
await world.page.waitForLoadState('domcontentloaded');
});
Then('I should see total player count', async function () {
await expect(world.page.locator('text=Total Players')).toBeVisible();
console.log('🌍 Verified total players section is visible');
});
Then('I should see active tournament count', async function () {
// Use more specific locator to find the stats card
await expect(world.page.locator('dt:has-text("Tournaments")').first()).toBeVisible();
console.log('🌍 Verified tournaments section is visible');
});
Then('I should see the activity feed section', async function () {
await expect(world.page.locator('text=Recent Activity')).toBeVisible();
console.log('🌍 Verified activity feed section is visible');
});
Then('I should see recent player registrations', async function () {
// Check if there are any activities in the feed
const activityItems = await world.page.locator('.divide-y.divide-gray-200 li').count();
console.log(`🌍 Found ${activityItems} activity items`);
// Also check for the activity text
const content = await world.page.content();
const hasActivityText = content.includes('Test Activity Player');
console.log(`🌍 Activity text found in page: ${hasActivityText}`);
expect(activityItems).toBeGreaterThan(0);
});
When('I go to the player management page', async function () {
console.log('🌍 Going to player management page');
await world.page.goto(`${world.baseURL}/admin/players`);
await world.page.waitForLoadState('domcontentloaded');
});
When('I search for {string}', async function (searchTerm: string) {
console.log(`🌍 Searching for: ${searchTerm}`);
await world.page.fill('input[name="search"]', searchTerm);
await world.page.waitForTimeout(500); // Wait for search to execute
});
Then('I should see search results', async function () {
// Check if player table is visible
await expect(world.page.locator('table')).toBeVisible();
console.log('🌍 Verified search results are displayed');
});
When('I go to the club settings page', async function () {
console.log('🌍 Going to club settings page');
await world.page.goto(`${world.baseURL}/admin/settings`);
await world.page.waitForLoadState('domcontentloaded');
});
When('I update the club name', async function () {
console.log('🌍 Updating club name');
await world.page.fill('input[id="clubName"]', 'Test Club Updated');
});
When('I save the settings', async function () {
console.log('🌍 Saving settings');
await world.page.click('button:has-text("Save Settings")');
await world.page.waitForTimeout(1000); // Wait for save to complete
});
Then('the settings should be saved successfully', async function () {
await expect(world.page.locator('text=Settings saved successfully')).toBeVisible();
console.log('🌍 Verified settings were saved successfully');
});
// Rankings Page Steps
When('I go to the rankings page', async function () {
console.log('🌍 Going to rankings page');
await world.page.goto(`${world.baseURL}/rankings`);
await world.page.waitForLoadState('domcontentloaded');
});
Then('I should see {string} in the page heading', async function (heading: string) {
// Use a more flexible selector that matches text content
await expect(world.page.locator(`text=${heading}`)).toBeVisible();
console.log(`🌍 Verified heading "${heading}" is visible`);
});
Then('I should see a rankings table', async function () {
await expect(world.page.locator('table')).toBeVisible();
console.log('🌍 Verified rankings table is visible');
});
Then('I should see a rankings table with columns', async function () {
const table = world.page.locator('table');
await expect(table).toBeVisible();
const headerCount = await world.page.locator('th').count();
expect(headerCount).toBeGreaterThan(0);
console.log(`🌍 Verified rankings table has ${headerCount} columns`);
});
Then('the table should have column headers', async function () {
const headerCount = await world.page.locator('th').count();
expect(headerCount).toBeGreaterThan(0);
console.log(`🌍 Verified table has ${headerCount} column headers`);
});
Given('I am not logged in', async function () {
// This is just a documentation step - the test environment starts fresh
console.log('🌍 User is not logged in (fresh session)');
});
Then('I should be on the rankings page', async function () {
const currentUrl = world.page.url();
console.log(`🌍 Checking current URL: ${currentUrl}`);
expect(currentUrl).toContain('/rankings');
});
Then('I should see the rankings table', async function () {
await expect(world.page.locator('table')).toBeVisible();
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)/);
});
// View As Role Steps
When('I view the navigation', async function () {
await world.page.waitForLoadState('networkidle');
await world.page.waitForTimeout(1000);
console.log('🌍 Viewing navigation');
});
Then('I should see the role switcher dropdown', async function () {
const switcher = world.page.locator('[data-testid="role-switcher"]');
await expect(switcher).toBeVisible({ timeout: 5000 });
console.log('🌍 Verified role switcher dropdown is visible');
});
Then('the role switcher should default to {string}', async function (expectedText: string) {
const switcher = world.page.locator('[data-testid="role-switcher"]');
const selectedValue = await switcher.inputValue();
const selectedText = await switcher.locator('option:checked').textContent();
console.log(`🌍 Dropdown selected text: "${selectedText}", value: "${selectedValue}"`);
expect(selectedText?.trim()).toBe(expectedText);
});
When('I select {string} from the role switcher', async function (optionText: string) {
const switcher = world.page.locator('[data-testid="role-switcher"]');
await switcher.selectOption({ label: optionText });
await world.page.waitForTimeout(500);
console.log(`🌍 Selected "${optionText}" from role switcher`);
});
Then('I should see the player navigation links', async function () {
await expect(world.page.locator('nav a:has-text("Rankings")')).toBeVisible();
await expect(world.page.locator('nav a:has-text("Tournaments")')).toBeVisible();
console.log('🌍 Verified player navigation links are visible');
});
Then('I should not see the {string} link', async function (linkText: string) {
const link = world.page.locator(`nav a:has-text("${linkText}")`);
await expect(link).not.toBeVisible({ timeout: 3000 });
console.log(`🌍 Verified "${linkText}" nav link is not visible`);
});
Then('I should see the {string} link', async function (linkText: string) {
const link = world.page.locator(`nav a:has-text("${linkText}")`);
await expect(link).toBeVisible({ timeout: 5000 });
console.log(`🌍 Verified "${linkText}" nav link is visible`);
});
Then('I should see a banner indicating I am viewing as {string}', async function (roleName: string) {
const banner = world.page.locator(`text=Viewing as ${roleName}`);
await expect(banner).toBeVisible({ timeout: 5000 });
console.log(`🌍 Verified viewing as ${roleName} banner is visible`);
});
Then('I should not see the viewing as banner', async function () {
const banner = world.page.locator('[data-testid="reset-view-as"]');
await expect(banner).not.toBeVisible({ timeout: 3000 });
console.log('🌍 Verified viewing as banner is not visible');
});
+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;
} }
+7 -7
View File
@@ -191,7 +191,7 @@ test.describe('Elo Rating Updates', () => {
}); });
await page.goto('/admin'); await page.goto('/admin');
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
// Check if we're logged in // Check if we're logged in
const content = await page.content(); const content = await page.content();
@@ -211,19 +211,19 @@ test.describe('Elo Rating Updates', () => {
console.log('Logging out and logging in as admin...'); console.log('Logging out and logging in as admin...');
await page.click('button:has-text("Sign out")'); await page.click('button:has-text("Sign out")');
await page.waitForURL('/auth/login'); await page.waitForURL('/auth/login');
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
} }
if (!isLoggedIn || page.url().includes('/players/')) { if (!isLoggedIn || page.url().includes('/players/')) {
// If not logged in or logged in as regular user, log in as admin // If not logged in or logged in as regular user, log in as admin
console.log('Logging in as admin...'); console.log('Logging in as admin...');
await page.goto('/auth/login'); await page.goto('/auth/login');
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
await page.fill('input[name="email"]', adminEmail); await page.fill('input[name="email"]', adminEmail);
await page.fill('input[name="password"]', adminPassword); await page.fill('input[name="password"]', adminPassword);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 }); await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
} }
// Verify tournament ownership // Verify tournament ownership
@@ -238,7 +238,7 @@ test.describe('Elo Rating Updates', () => {
await page.goto('/admin/matches/upload'); await page.goto('/admin/matches/upload');
// Wait for page to load and tournaments to be fetched // Wait for page to load and tournaments to be fetched
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
// Wait for tournament dropdown to be ready // Wait for tournament dropdown to be ready
@@ -313,7 +313,7 @@ ${tournament.id},2,1,${player1.name},${player3.name},10,${player2.name},${player
// Navigate back to upload page for second match // Navigate back to upload page for second match
await page.goto('/admin/matches/upload'); await page.goto('/admin/matches/upload');
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
// Re-select the tournament for the second upload // Re-select the tournament for the second upload
@@ -384,7 +384,7 @@ ${tournament.id},2,1,${player1.name},${player3.name},10,${player2.name},${player
await page.goto(`/players/${player.id}/profile`); await page.goto(`/players/${player.id}/profile`);
// Wait for page to load // Wait for page to load
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
// Verify rating is displayed (from player.currentElo) // Verify rating is displayed (from player.currentElo)
await expect(page.locator('text=1750')).toBeVisible(); await expect(page.locator('text=1750')).toBeVisible();
+6 -6
View File
@@ -92,10 +92,10 @@ test.describe.serial('Epic 1: User Logout', () => {
test('Logout button appears in navigation when logged in', async ({ page }) => { test('Logout button appears in navigation when logged in', async ({ page }) => {
// Login first // Login first
await page.goto('http://localhost:3000/auth/login'); await page.goto('http://localhost:3000/auth/login');
// Wait for page to load // Wait for JavaScript to be ready
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
await page.waitForSelector('form'); await page.waitForSelector('form');
await page.waitForSelector('button[type="submit"]:not([disabled])'); await page.waitForSelector('button[type="submit"]:not([disabled])');
@@ -129,7 +129,7 @@ test.describe.serial('Epic 1: User Logout', () => {
// Navigate to home page to check navigation (session should persist) // Navigate to home page to check navigation (session should persist)
// Use reload to ensure session is read from cookies // Use reload to ensure session is read from cookies
await page.reload(); await page.reload();
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
// Wait a moment for the navigation component to update // Wait a moment for the navigation component to update
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@@ -166,7 +166,7 @@ test.describe.serial('Epic 1: User Logout', () => {
// Navigate to home using reload to ensure session is loaded // Navigate to home using reload to ensure session is loaded
await page.reload(); await page.reload();
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
// Wait for logout button to appear // Wait for logout button to appear
await page.waitForSelector('text=Sign out', { timeout: 10000 }); await page.waitForSelector('text=Sign out', { timeout: 10000 });
@@ -190,7 +190,7 @@ test.describe.serial('Epic 1: User Logout', () => {
// Navigate to home using reload to ensure session is loaded // Navigate to home using reload to ensure session is loaded
await page.reload(); await page.reload();
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
// Wait for logout button to appear // Wait for logout button to appear
await page.waitForSelector('text=Sign out', { timeout: 10000 }); await page.waitForSelector('text=Sign out', { timeout: 10000 });
+2 -2
View File
@@ -63,8 +63,8 @@ test.describe.serial('Epic 1: User Registration', () => {
test('Registration with valid data creates account', async ({ page }) => { test('Registration with valid data creates account', async ({ page }) => {
await page.goto('http://localhost:3000/auth/register'); await page.goto('http://localhost:3000/auth/register');
// Wait for page to load // Wait for JavaScript to be ready
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
await page.waitForSelector('form'); await page.waitForSelector('form');
await page.waitForSelector('button[type="submit"]:not([disabled])'); await page.waitForSelector('button[type="submit"]:not([disabled])');
+47
View File
@@ -0,0 +1,47 @@
/**
* Epic 3: Rankings & Public Data
* Acceptance Test: Player Rankings Page
*
* User Story: As a visitor, I want to view player rankings so that I can see top players
*
* Acceptance Criteria:
* - Sortable rankings table
* - Columns: Rank, Name, Elo, Win Rate, Games Played
* - Search/filter functionality
* - Pagination
*/
import { test, expect } from '@playwright/test';
test.describe('Epic 3: Rankings Page', () => {
test('Rankings page loads and displays rankings table', async ({ page }) => {
await page.goto('http://localhost:3000/rankings');
// Check page title or heading
await expect(page.locator('h1, h2')).toContainText(/rankings?/i);
// Check for rankings table
await expect(page.locator('table')).toBeVisible();
});
test('Rankings table displays player columns', async ({ page }) => {
await page.goto('http://localhost:3000/rankings');
// Check for expected column headers
const table = page.locator('table');
await expect(table).toBeVisible();
// Check for column headers (may vary based on implementation)
const headerCount = await page.locator('th').count();
expect(headerCount).toBeGreaterThan(0);
});
test('Rankings page is publicly accessible (no login required)', async ({ page }) => {
// Navigate directly to rankings without logging in
await page.goto('http://localhost:3000/rankings');
// Page should load without redirecting to login
await expect(page).toHaveURL(/.*rankings.*/);
await expect(page.locator('body')).toBeVisible();
});
});
+2 -2
View File
@@ -173,7 +173,7 @@ export default async function globalSetup(config: FullConfig) {
// Navigate to admin page to refresh session // Navigate to admin page to refresh session
console.log('Navigating to admin page for admin user...'); console.log('Navigating to admin page for admin user...');
await page.goto(`${baseURL}/admin`); await page.goto(`${baseURL}/admin`);
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
console.log('Admin page loaded:', page.url()); console.log('Admin page loaded:', page.url());
// Wait a bit to ensure session is refreshed // Wait a bit to ensure session is refreshed
@@ -181,7 +181,7 @@ export default async function globalSetup(config: FullConfig) {
// Refresh the page to force session reload // Refresh the page to force session reload
await page.reload(); await page.reload();
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
console.log('Page reloaded'); console.log('Page reloaded');
// Save the authentication state // Save the authentication state
+126
View File
@@ -0,0 +1,126 @@
import { test, expect } from '@playwright/test'
import { prisma } from '@/lib/prisma'
import { createTestPlayer, cleanupTestRecords, getCreatedRecordCounts } from '@/__tests__/test-utils'
test.describe('Home Page', () => {
test.beforeEach(async () => {
// Clean up any existing test records before each test
await cleanupTestRecords();
});
test.afterEach(async () => {
// Clean up test records after each test
await cleanupTestRecords();
});
test('should display top 10 players', async ({ page }) => {
// Create some test players with unique names and very high Elo to ensure they're in top 10
const timestamp = Date.now()
const players = []
for (let i = 0; i < 3; i++) {
const playerName = `Home Test Player ${timestamp} ${i + 1}`
const player = await createTestPlayer({
name: playerName,
currentElo: 2000 - i * 10,
})
players.push(player)
}
// Navigate to home page
await page.goto('/')
// Check that the page loads
await expect(page.locator('text=Top 10 Players')).toBeVisible()
// Check that at least one of our test players is displayed
// Use a more specific locator to avoid multiple matches
await expect(
page.locator(`a:has-text("Home Test Player ${timestamp} 1")`)
).toBeVisible()
// Verify cleanup will work
const counts = getCreatedRecordCounts();
expect(counts.players).toBe(3);
})
test('should display club president', async ({ page }) => {
const timestamp = Date.now()
// Create a club admin user
const clubAdmin = await prisma.user.create({
data: {
email: `president-${timestamp}@example.com`,
name: `Club President ${timestamp}`,
role: 'club_admin',
},
})
// Navigate to home page
await page.goto('/')
// Check that the club president section is visible
await expect(page.locator('text=Club President')).toBeVisible()
// Clean up
await prisma.user.delete({ where: { id: clubAdmin.id } })
})
test('should display most recent tournament', async ({ page }) => {
const timestamp = Date.now()
// Create a tournament with a future date to ensure it's the most recent
const tournament = await prisma.event.create({
data: {
name: `Recent Tournament ${timestamp}`,
eventType: 'tournament',
eventDate: new Date(Date.now() + 86400000), // Tomorrow
status: 'completed',
},
})
// Create players for the match
const player1Name = `Home Match Player 1 ${timestamp}`
const player1 = await prisma.player.create({
data: { name: player1Name, normalizedName: player1Name.toLowerCase(), currentElo: 1500 },
})
const player2Name = `Home Match Player 2 ${timestamp}`
const player2 = await prisma.player.create({
data: { name: player2Name, normalizedName: player2Name.toLowerCase(), currentElo: 1480 },
})
const player3Name = `Home Match Player 3 ${timestamp}`
const player3 = await prisma.player.create({
data: { name: player3Name, normalizedName: player3Name.toLowerCase(), currentElo: 1450 },
})
const player4Name = `Home Match Player 4 ${timestamp}`
const player4 = await prisma.player.create({
data: { name: player4Name, normalizedName: player4Name.toLowerCase(), currentElo: 1420 },
})
// Create a match in the tournament
await prisma.match.create({
data: {
eventId: tournament.id,
player1P1Id: player1.id,
player1P2Id: player2.id,
player2P1Id: player3.id,
player2P2Id: player4.id,
team1Score: 10,
team2Score: 5,
status: 'completed',
playedAt: new Date(),
},
})
// Navigate to home page
await page.goto('/')
// Check that the tournament section is visible
await expect(page.locator('text=Most Recent Tournament')).toBeVisible()
await expect(page.locator(`text=Recent Tournament ${timestamp}`)).toBeVisible()
// Clean up
await prisma.match.deleteMany({ where: { eventId: tournament.id } })
await prisma.event.delete({ where: { id: tournament.id } })
await prisma.player.deleteMany({
where: { id: { in: [player1.id, player2.id, player3.id, player4.id] } },
})
})
})
@@ -241,7 +241,7 @@ test.describe.serial('Tournament with 10 Participants and Variable Team Durabili
await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}`); await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}`);
// Wait for page to load and data to be fetched // Wait for page to load and data to be fetched
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle');
// Wait for the page content to appear (not just "Loading...") // Wait for the page content to appear (not just "Loading...")
await expect(page.locator('text=Matchups')).toBeVisible({ timeout: 15000 }); await expect(page.locator('text=Matchups')).toBeVisible({ timeout: 15000 });
+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
+20 -20
View File
@@ -1,6 +1,6 @@
{ {
"name": "euchre_camp", "name": "euchre_camp",
"version": "0.1.15", "version": "0.1.6",
"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",
@@ -17,7 +17,7 @@
"test:acceptance:headed": "bun x playwright test e2e/ --headed", "test:acceptance:headed": "bun x playwright test e2e/ --headed",
"test:acceptance:cucumber": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts", "test:acceptance:cucumber": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts",
"test:acceptance:cucumber:pretty": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts --format pretty:cucumber-pretty", "test:acceptance:cucumber:pretty": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts --format pretty:cucumber-pretty",
"test:acceptance:cucumber:prod": "bun run build && (trap 'kill $(jobs -p) 2>/dev/null || true' EXIT; DATABASE_URL=${DATABASE_URL:-$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"')} DATABASE_PROVIDER=${DATABASE_PROVIDER:-postgresql} bun run start & echo 'Waiting for server to start...'; for i in {1..30}; do if curl -s http://localhost:3000 > /dev/null 2>&1; then echo 'Server ready!'; break; fi; sleep 1; done; npm run test:acceptance:cucumber)", "test:acceptance:cucumber:prod": "bun run build && (DATABASE_URL=${DATABASE_URL:-$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"')} DATABASE_PROVIDER=${DATABASE_PROVIDER:-postgresql} bun run start & SERVER_PID=$! && sleep 15 && npm run test:acceptance:cucumber; kill $SERVER_PID 2>/dev/null || true)",
"cucumber": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts", "cucumber": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts",
"db:switch": "bun run scripts/switch-database.js", "db:switch": "bun run scripts/switch-database.js",
"db:setup-postgres": "bun run scripts/setup-postgres.js", "db:setup-postgres": "bun run scripts/setup-postgres.js",
@@ -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,63 +0,0 @@
-- DropForeignKey
ALTER TABLE "matches" DROP CONSTRAINT "matches_player1P1Id_fkey";
-- DropForeignKey
ALTER TABLE "matches" DROP CONSTRAINT "matches_player1P2Id_fkey";
-- DropForeignKey
ALTER TABLE "matches" DROP CONSTRAINT "matches_player2P1Id_fkey";
-- DropForeignKey
ALTER TABLE "matches" DROP CONSTRAINT "matches_player2P2Id_fkey";
-- CreateTable
CREATE TABLE "activities" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"description" TEXT NOT NULL,
"userId" TEXT,
"playerId" INTEGER,
"eventId" INTEGER,
"matchId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "activities_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "club_settings" (
"id" SERIAL NOT NULL,
"clubName" TEXT NOT NULL DEFAULT 'Euchre Club',
"defaultEloRating" INTEGER NOT NULL DEFAULT 1200,
"partnershipEnabled" BOOLEAN NOT NULL DEFAULT true,
"notificationsEnabled" BOOLEAN NOT NULL DEFAULT true,
"matchVerification" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "club_settings_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "matches" ADD CONSTRAINT "matches_player1P1Id_fkey" FOREIGN KEY ("player1P1Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "matches" ADD CONSTRAINT "matches_player1P2Id_fkey" FOREIGN KEY ("player1P2Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "matches" ADD CONSTRAINT "matches_player2P1Id_fkey" FOREIGN KEY ("player2P1Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "matches" ADD CONSTRAINT "matches_player2P2Id_fkey" FOREIGN KEY ("player2P2Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "activities" ADD CONSTRAINT "activities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "activities" ADD CONSTRAINT "activities_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "activities" ADD CONSTRAINT "activities_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "activities" ADD CONSTRAINT "activities_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "matches"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-35
View File
@@ -27,7 +27,6 @@ model Player {
partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2") partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2")
partnershipStats PartnershipStat[] @relation("StatPlayer1") partnershipStats PartnershipStat[] @relation("StatPlayer1")
partnershipStats2 PartnershipStat[] @relation("StatPlayer2") partnershipStats2 PartnershipStat[] @relation("StatPlayer2")
activities Activity[]
user User? user User?
eloRating EloRating? eloRating EloRating?
glicko2Rating Glicko2Rating? glicko2Rating Glicko2Rating?
@@ -54,7 +53,6 @@ model User {
ownedTournaments Event[] @relation("TournamentOwner") ownedTournaments Event[] @relation("TournamentOwner")
createdMatches Match[] @relation("MatchCreator") createdMatches Match[] @relation("MatchCreator")
sessions Session[] sessions Session[]
activities Activity[]
player Player? @relation(fields: [playerId], references: [id]) player Player? @relation(fields: [playerId], references: [id])
@@map("users") @@map("users")
@@ -81,7 +79,6 @@ model Event {
owner User? @relation("TournamentOwner", fields: [ownerId], references: [id]) owner User? @relation("TournamentOwner", fields: [ownerId], references: [id])
matches Match[] matches Match[]
rounds TournamentRound[] rounds TournamentRound[]
activities Activity[]
// Team configuration fields // Team configuration fields
teamDurability String @default("permanent") // permanent, variable, per_round teamDurability String @default("permanent") // permanent, variable, per_round
@@ -170,7 +167,6 @@ model Match {
isCasual Boolean @default(false) isCasual Boolean @default(false)
bracketMatchups BracketMatchup[] bracketMatchups BracketMatchup[]
eloSnapshots EloSnapshot[] eloSnapshots EloSnapshot[]
activities Activity[]
createdBy User? @relation("MatchCreator", fields: [createdById], references: [id]) createdBy User? @relation("MatchCreator", fields: [createdById], references: [id])
event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade) event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade)
player1P1 Player? @relation("MatchPlayer1", fields: [player1P1Id], references: [id]) player1P1 Player? @relation("MatchPlayer1", fields: [player1P1Id], references: [id])
@@ -329,34 +325,3 @@ model OpenSkillRating {
@@map("open_skill_ratings") @@map("open_skill_ratings")
} }
model Activity {
id Int @id @default(autoincrement())
type String // "player_registration", "tournament_created", "match_completed", "partnership_recorded"
description String
userId String?
playerId Int?
eventId Int?
matchId Int?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id])
player Player? @relation(fields: [playerId], references: [id])
event Event? @relation(fields: [eventId], references: [id])
match Match? @relation(fields: [matchId], references: [id])
@@map("activities")
}
model ClubSettings {
id Int @id @default(autoincrement())
clubName String @default("Euchre Club")
defaultEloRating Int @default(1200)
partnershipEnabled Boolean @default(true)
notificationsEnabled Boolean @default(true)
matchVerification Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("club_settings")
}
-46
View File
@@ -59,17 +59,6 @@ export default async function AdminDashboard() {
}), }),
]) as [number, number, number, EventModel[]] ]) as [number, number, number, EventModel[]]
// Get recent activities (using any type to bypass TypeScript error for now)
const recentActivities = await (prisma as any).activity.findMany({
take: 10,
orderBy: { createdAt: "desc" },
include: {
user: { select: { name: true } },
player: { select: { name: true } },
event: { select: { name: true } },
},
})
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Navigation /> <Navigation />
@@ -242,41 +231,6 @@ export default async function AdminDashboard() {
</Link> in the rankings page. </Link> in the rankings page.
</p> </p>
</div> </div>
{/* Recent Activity Feed */}
<div className="bg-white shadow rounded-lg p-6 mt-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-medium text-gray-900">Recent Activity</h2>
<Link
href="/admin/activity"
className="text-green-600 hover:text-green-900 text-sm font-medium"
>
View All
</Link>
</div>
{recentActivities.length > 0 ? (
<ul className="divide-y divide-gray-200">
{recentActivities.map((activity: any) => (
<li key={activity.id} className="py-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-900">{activity.description}</p>
<p className="text-xs text-gray-500">
{new Date(activity.createdAt).toLocaleDateString()} at{' '}
{new Date(activity.createdAt).toLocaleTimeString()}
</p>
</div>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{activity.type}
</span>
</div>
</li>
))}
</ul>
) : (
<p className="text-gray-500">No recent activities.</p>
)}
</div>
</div> </div>
</main> </main>
</div> </div>
+1 -27
View File
@@ -231,34 +231,8 @@ export default function AdminPlayersPage() {
</div> </div>
)} )}
{/* Search and Filter Controls */}
<div className="bg-white shadow rounded-lg p-4 mb-4">
<div className="flex items-center space-x-4">
<div className="flex-1">
<input
type="text"
name="search"
placeholder="Search players by name..."
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
onChange={async (e) => {
const search = e.target.value
try {
const response = await fetch(`/api/players?search=${encodeURIComponent(search)}`)
if (response.ok) {
const data = await response.json()
setPlayers(data)
}
} catch (err) {
console.error('Search failed:', err)
}
}}
/>
</div>
</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>
-224
View File
@@ -1,224 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import Navigation from "@/components/Navigation"
import { redirect } from "next/navigation"
import { getSession } from "@/lib/auth-simple"
interface ClubSettings {
id: number
clubName: string
defaultEloRating: number
partnershipEnabled: boolean
notificationsEnabled: boolean
matchVerification: boolean
}
export default function ClubSettingsPage() {
const [settings, setSettings] = useState<ClubSettings | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [formSettings, setFormSettings] = useState<Partial<ClubSettings>>({})
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
const response = await fetch("/api/admin/settings")
if (!response.ok) {
throw new Error("Failed to fetch settings")
}
const data = await response.json()
setSettings(data)
setFormSettings(data || {})
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch settings")
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
setError("")
setSuccess("")
try {
const response = await fetch("/api/admin/settings", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formSettings),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to save settings")
}
const updatedSettings = await response.json()
setSettings(updatedSettings)
setSuccess("Settings saved successfully!")
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to save settings")
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<p className="text-gray-500">Loading settings...</p>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* Page Header */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h1 className="text-2xl font-bold text-gray-900">Club Settings</h1>
<p className="text-gray-500 mt-1">
Configure your club's default settings and preferences.
</p>
</div>
{/* Settings Form */}
<div className="bg-white shadow rounded-lg p-6">
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
{success && (
<div className="mb-4 p-4 bg-green-100 text-green-700 rounded">
{success}
</div>
)}
<div className="space-y-6">
{/* Club Name */}
<div>
<label htmlFor="clubName" className="block text-sm font-medium text-gray-700 mb-2">
Club Name
</label>
<input
type="text"
id="clubName"
value={formSettings.clubName || ""}
onChange={(e) => setFormSettings({ ...formSettings, clubName: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
/>
</div>
{/* Default Elo Rating */}
<div>
<label htmlFor="defaultEloRating" className="block text-sm font-medium text-gray-700 mb-2">
Default Elo Rating
</label>
<input
type="number"
id="defaultEloRating"
value={formSettings.defaultEloRating || 1000}
onChange={(e) => setFormSettings({ ...formSettings, defaultEloRating: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
/>
</div>
{/* Partnership Tracking */}
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700">Partnership Tracking</label>
<p className="text-sm text-gray-500">Enable partnership performance analytics</p>
</div>
<button
type="button"
onClick={() => setFormSettings({ ...formSettings, partnershipEnabled: !formSettings.partnershipEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
formSettings.partnershipEnabled ? 'bg-green-600' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
formSettings.partnershipEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Notifications */}
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700">Notifications</label>
<p className="text-sm text-gray-500">Send email notifications for updates</p>
</div>
<button
type="button"
onClick={() => setFormSettings({ ...formSettings, notificationsEnabled: !formSettings.notificationsEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
formSettings.notificationsEnabled ? 'bg-green-600' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
formSettings.notificationsEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Match Verification */}
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700">Match Verification</label>
<p className="text-sm text-gray-500">Require admin verification for match results</p>
</div>
<button
type="button"
onClick={() => setFormSettings({ ...formSettings, matchVerification: !formSettings.matchVerification })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
formSettings.matchVerification ? 'bg-green-600' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
formSettings.matchVerification ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* Save Button */}
<div className="mt-8 flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</div>
</main>
</div>
)
}
+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>
-34
View File
@@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth-simple'
export async function GET(request: NextRequest) {
const session = await getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20')
const offset = parseInt(searchParams.get('offset') || '0')
const type = searchParams.get('type')
const where: any = {}
if (type) {
where.type = type
}
const activities = await prisma.activity.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
user: { select: { name: true } },
player: { select: { name: true } },
event: { select: { name: true } },
},
})
return NextResponse.json(activities)
}
-46
View File
@@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth-simple'
export async function GET() {
const session = await getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const settings = await (prisma as any).clubSettings.findFirst()
return NextResponse.json(settings)
}
export async function PATCH(request: NextRequest) {
const session = await getSession()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const data = await request.json()
// Get the existing settings record (should be id: 1 or first record)
let settings = await (prisma as any).clubSettings.findFirst()
if (!settings) {
// Create default settings if none exist
settings = await (prisma as any).clubSettings.create({
data: {
clubName: 'Euchre Club',
defaultEloRating: 1200,
partnershipEnabled: true,
notificationsEnabled: true,
matchVerification: false,
},
})
}
// Update the settings
const updatedSettings = await (prisma as any).clubSettings.update({
where: { id: settings.id },
data,
})
return NextResponse.json(updatedSettings)
}
-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 });
}
}
+3 -17
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
/** /**
@@ -6,22 +6,10 @@ import { prisma } from "@/lib/prisma";
* *
* Get all players with their user associations * Get all players with their user associations
* This is a public endpoint (no authentication required) * This is a public endpoint (no authentication required)
* Supports search query parameter
*/ */
export async function GET(request: NextRequest) { export async function GET() {
try { try {
const { searchParams } = new URL(request.url)
const search = searchParams.get('search') || ''
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
const where: any = {}
if (search) {
where.name = { contains: search, mode: 'insensitive' }
}
const players = await prisma.player.findMany({ const players = await prisma.player.findMany({
where,
include: { include: {
user: { user: {
select: { select: {
@@ -29,9 +17,7 @@ export async function GET(request: NextRequest) {
}, },
}, },
}, },
orderBy: { currentElo: 'desc' }, orderBy: { name: "asc" },
take: limit,
skip: offset,
}); });
return NextResponse.json(players); return NextResponse.json(players);
+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)
+3 -6
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { SessionProvider } from "@/components/SessionProvider"; import { SessionProvider } from "@/components/SessionProvider";
import { RoleSwitcherProvider } from "@/components/RoleSwitcher";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
const inter = { const inter = {
@@ -23,12 +22,10 @@ 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>
<RoleSwitcherProvider> {children}
{children} <Footer />
<Footer />
</RoleSwitcherProvider>
</SessionProvider> </SessionProvider>
</body> </body>
</html> </html>
+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.")
+93 -135
View File
@@ -4,13 +4,12 @@ import Link from "next/link"
import { useSession } from "./SessionProvider" import { useSession } from "./SessionProvider"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRoleSwitcher } from "./RoleSwitcher"
export default function Navigation() { export default function Navigation() {
const { session, loading } = useSession() const { session, loading } = useSession()
const [userRole, setUserRole] = useState<string | null>(null) const [userRole, setUserRole] = useState<string | null>(null)
const { viewAsRole, setViewAsRole, effectiveRole } = useRoleSwitcher()
// Fetch user role whenever session changes
useEffect(() => { useEffect(() => {
const fetchUserRole = async () => { const fetchUserRole = async () => {
const userId = (session?.user as { id?: string })?.id const userId = (session?.user as { id?: string })?.id
@@ -35,158 +34,117 @@ export default function Navigation() {
}, [session]) }, [session])
const handleLogout = async () => { const handleLogout = async () => {
setViewAsRole(null)
await authClient.signOut() await authClient.signOut()
window.location.href = '/auth/login' window.location.href = '/auth/login'
} }
const displayRole = effectiveRole || userRole // Determine wordmark href based on session and role
const isSiteAdmin = userRole === "site_admin" // If session exists but role is not yet loaded, use /rankings as default for players
const wordmarkHref = session const wordmarkHref = session
? (displayRole === "club_admin" || displayRole === "site_admin") ? (userRole === "club_admin" || userRole === "site_admin")
? "/admin" ? "/admin"
: "/rankings" : "/rankings"
: "/" : "/";
const roleLabels: Record<string, string> = {
player: "Player",
tournament_admin: "Tournament Admin",
club_admin: "Club Admin",
site_admin: "Site Admin",
}
return ( return (
<> <nav className="bg-white shadow-sm">
{viewAsRole && ( <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-yellow-50 border-b border-yellow-200 px-4 py-2"> <div className="flex justify-between h-16">
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="flex items-center">
<p className="text-sm text-yellow-800"> <Link
<span className="font-medium">Viewing as {roleLabels[viewAsRole]}</span> href="/wordmark-redirect"
{" "}&mdash; you are seeing what a {roleLabels[viewAsRole]?.toLowerCase()} would see. className="text-xl font-bold text-gray-900 no-underline"
</p>
<button
onClick={() => setViewAsRole(null)}
className="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline"
data-testid="reset-view-as"
> >
Reset to Site Admin EuchreCamp
</button> </Link>
</div> <div className="hidden md:ml-6 md:flex md:space-x-8">
</div>
)}
<nav className="bg-white shadow-sm">
<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 items-center min-w-0 overflow-hidden">
<Link <Link
href="/" href="/rankings"
className="text-xl font-bold text-gray-900 no-underline flex-shrink-0" 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"
> >
EuchreCamp Rankings
</Link> </Link>
<div className="hidden md:ml-6 md:flex md:space-x-8 min-w-0 overflow-hidden"> {session && (
<Link <>
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"
>
Rankings
</Link>
{session && (
<>
<Link
href="/admin/tournaments"
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"
>
Tournaments
</Link>
{(displayRole === "club_admin" || displayRole === "site_admin") && (
<>
<Link
href="/admin"
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"
>
Admin
</Link>
<Link
href="/admin/matches"
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"
>
Matches
</Link>
<Link
href="/admin/players"
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"
>
Players
</Link>
<Link
href="/admin/users"
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"
>
Users
</Link>
<Link
href="/admin/matches/upload"
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"
>
Upload Matches
</Link>
</>
)}
</>
)}
</div>
</div>
<div className="flex items-center min-w-0 overflow-hidden space-x-4">
{isSiteAdmin && (
<select
value={viewAsRole || ""}
onChange={(e) => setViewAsRole(e.target.value ? e.target.value as "player" | "tournament_admin" | "club_admin" : null)}
className="text-sm border border-gray-300 rounded-md px-2 py-1 bg-white text-gray-700 focus:outline-none focus:ring-green-500 focus:border-green-500"
data-testid="role-switcher"
>
<option value="">Viewing as Site Admin</option>
<option value="player">View as Player</option>
<option value="tournament_admin">View as Tournament Admin</option>
<option value="club_admin">View as Club Admin</option>
</select>
)}
{loading ? (
<div className="text-gray-500">Loading...</div>
) : session ? (
<div className="flex items-center space-x-4">
<span className="text-gray-700 text-sm font-medium">
{(session.user as { name?: string; email?: string })?.name ||
(session.user as { name?: string; email?: string })?.email}
</span>
<button
onClick={handleLogout}
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
>
Sign out
</button>
</div>
) : (
<div className="flex items-center space-x-4">
<Link <Link
href="/auth/login" href="/admin/tournaments"
className="text-gray-500 hover:text-gray-700 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"
> >
Sign in Tournaments
</Link> </Link>
<Link {(userRole === "club_admin" || userRole === "site_admin") && (
href="/auth/register" <>
className="bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium hover:bg-green-700" <Link
> href="/admin"
Sign up 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"
</Link> >
</div> Admin
</Link>
<Link
href="/admin/matches"
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"
>
Matches
</Link>
<Link
href="/admin/players"
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"
>
Players
</Link>
<Link
href="/admin/users"
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"
>
Users
</Link>
<Link
href="/admin/matches/upload"
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"
>
Upload Matches
</Link>
</>
)}
</>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center">
{loading ? (
<div className="text-gray-500">Loading...</div>
) : session ? (
<div className="flex items-center space-x-4">
<span className="text-gray-700 text-sm font-medium">
{(session.user as { name?: string; email?: string })?.name ||
(session.user as { name?: string; email?: string })?.email}
</span>
<button
onClick={handleLogout}
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
>
Sign out
</button>
</div>
) : (
<div className="flex items-center space-x-4">
<Link
href="/auth/login"
className="text-gray-500 hover:text-gray-700 text-sm font-medium"
>
Sign in
</Link>
<Link
href="/auth/register"
className="bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium hover:bg-green-700"
>
Sign up
</Link>
</div>
)}
</div>
</div> </div>
</nav> </div>
</> </nav>
) )
} }
+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}`)
} }
-37
View File
@@ -1,37 +0,0 @@
"use client"
import { createContext, useContext, useState, useCallback, ReactNode } from "react"
type ViewAsRole = "player" | "tournament_admin" | "club_admin" | null
interface RoleSwitcherContextType {
viewAsRole: ViewAsRole
setViewAsRole: (role: ViewAsRole) => void
effectiveRole: string | null
}
const RoleSwitcherContext = createContext<RoleSwitcherContextType | undefined>(undefined)
export function RoleSwitcherProvider({ children }: { children: ReactNode }) {
const [viewAsRole, setViewAsRole] = useState<ViewAsRole>(null)
const value = {
viewAsRole,
setViewAsRole: useCallback((role: ViewAsRole) => setViewAsRole(role), []),
effectiveRole: viewAsRole,
}
return (
<RoleSwitcherContext.Provider value={value}>
{children}
</RoleSwitcherContext.Provider>
)
}
export function useRoleSwitcher() {
const context = useContext(RoleSwitcherContext)
if (!context) {
throw new Error("useRoleSwitcher must be used within RoleSwitcherProvider")
}
return context
}
-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)
-29
View File
@@ -1,29 +0,0 @@
import { prisma } from './prisma'
export type ActivityType =
| 'player_registration'
| 'tournament_created'
| 'match_completed'
| 'partnership_recorded'
export interface ActivityData {
type: ActivityType
description: string
userId?: string
playerId?: number
eventId?: number
matchId?: number
}
export async function logActivity(data: ActivityData) {
return prisma.activity.create({
data: {
type: data.type,
description: data.description,
userId: data.userId,
playerId: data.playerId,
eventId: data.eventId,
matchId: data.matchId,
},
})
}