Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe47377d1 | |||
| c32239d557 | |||
| e3895d30b4 | |||
| aed554c337 | |||
| 14fbfacf9f | |||
| 9dc3fdb0e0 | |||
| edb05711ac | |||
| b2498decf8 | |||
| 877a38d744 | |||
| e6b41f65a5 | |||
| 88203869d5 | |||
| eff8e531aa | |||
| 4977043003 | |||
| 4794588034 | |||
| caefb0dcc0 | |||
| 9353ab1edc | |||
| 799f5e1c63 | |||
| 8f7ca1362a | |||
| 493ae0cf71 | |||
| 2292aa6d7f |
@@ -1,3 +1,31 @@
|
|||||||
|
## [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
|
## [0.1.13] - 2026-04-27
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
# Technical Findings: Next.js App Router Data Staleness in Production
|
||||||
|
|
||||||
|
## Issue Summary
|
||||||
|
|
||||||
|
**Problem**: Freshly generated database data (TournamentRound and BracketMatchup records) created via POST `/api/tournaments/[id]/schedule` fails to appear immediately after a browser refresh in production builds, despite the server component having `revalidate = 0` and `dynamic = "force-dynamic"`.
|
||||||
|
|
||||||
|
**Context**: The test suite `schedule-tab.test.ts` shows that data is created successfully in the database but the page refresh doesn't immediately display the new data in production builds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### 1. Next.js Data Cache Behavior
|
||||||
|
|
||||||
|
**Finding**: Next.js App Router caches `fetch` responses by default in production. While `revalidate = 0` and `dynamic = "force-dynamic"` disable full-route caching, they do not automatically disable the Data Cache for individual `fetch` requests.
|
||||||
|
|
||||||
|
**Evidence from codebase**:
|
||||||
|
- `src/app/admin/tournaments/[id]/schedule/page.tsx` sets:
|
||||||
|
```typescript
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const revalidate = 0
|
||||||
|
```
|
||||||
|
- However, the page uses Prisma directly, not `fetch`. The page query `prisma.event.findUnique` is not subject to Next.js fetch caching, but the **browser/client router cache** may still cause issues.
|
||||||
|
|
||||||
|
**Relevant Code Locations**:
|
||||||
|
- `src/app/admin/tournaments/[id]/schedule/page.tsx:14-16`
|
||||||
|
- `src/app/api/tournaments/[id]/schedule/route.ts:191-222` (POST transaction)
|
||||||
|
|
||||||
|
### 2. Prisma Client and Transaction Isolation
|
||||||
|
|
||||||
|
**Finding**: The POST endpoint uses `prisma.$transaction` to create rounds and matchups. In production with PostgreSQL, transaction isolation levels and connection pooling can cause visibility delays.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
```typescript
|
||||||
|
// src/app/api/tournaments/[id]/schedule/route.ts:191
|
||||||
|
const created = await prisma.$transaction(
|
||||||
|
schedule.map((round) =>
|
||||||
|
prisma.tournamentRound.create({...})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Potential Issues**:
|
||||||
|
- **Read Committed Isolation**: PostgreSQL's default `READ COMMITTED` isolation level ensures that once a transaction commits, subsequent queries see the new data. However, if the browser refresh happens immediately after the POST response, there might be a race condition.
|
||||||
|
- **Connection Pooling**: The Prisma client uses connection pooling. If the GET request (page load) uses a different connection than the POST request, and there's a replication delay (unlikely with SQLite/PostgreSQL single instance), it could see stale data.
|
||||||
|
|
||||||
|
**Evidence Locations**:
|
||||||
|
- `src/lib/prisma.ts:13-35` (Prisma client initialization)
|
||||||
|
- `src/app/api/tournaments/[id]/schedule/route.ts:191-222` (Transaction block)
|
||||||
|
|
||||||
|
### 3. Client-Side Router Cache
|
||||||
|
|
||||||
|
**Finding**: The Next.js App Router maintains a client-side cache for visited routes. Even when the server component revalidates, the client might serve a cached version from the client-side navigation cache.
|
||||||
|
|
||||||
|
**Evidence from research**:
|
||||||
|
- The GitHub discussion #51612 shows that `router.push` and browser refresh can still serve stale data due to client-side caching.
|
||||||
|
- The `ScheduleGenerator` component uses `fetch` to POST data but doesn't trigger a router refresh or invalidate the client cache.
|
||||||
|
|
||||||
|
**Code Locations**:
|
||||||
|
- `src/components/ScheduleGenerator.tsx:27-29` (POST request)
|
||||||
|
- `src/components/ScheduleGenerator.tsx:84` (Only calls `window.location.reload()` on DELETE, not POST)
|
||||||
|
|
||||||
|
### 4. Production vs Development Differences
|
||||||
|
|
||||||
|
**Finding**: Development mode (`next dev`) has more lenient caching behavior. Production builds (`next start`) aggressively cache by default.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- The test `schedule-tab.test.ts` passes in development but fails in production.
|
||||||
|
- The `ScheduleGenerator` component doesn't use `revalidatePath` or `revalidateTag` after successful POST.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specific Technical Findings
|
||||||
|
|
||||||
|
### Finding 1: Missing Cache Invalidation After POST
|
||||||
|
|
||||||
|
**Location**: `src/components/ScheduleGenerator.tsx:43-49`
|
||||||
|
|
||||||
|
**Issue**: After a successful POST request, the component updates local state (`result`) but doesn't:
|
||||||
|
1. Call `revalidatePath` (requires Server Action)
|
||||||
|
2. Call `revalidateTag` (requires Server Action)
|
||||||
|
3. Trigger a router refresh
|
||||||
|
4. Force a page reload
|
||||||
|
|
||||||
|
**Current Behavior**:
|
||||||
|
```typescript
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
// ... POST request ...
|
||||||
|
const data = await response.json()
|
||||||
|
setResult({
|
||||||
|
roundsCreated: data.roundsCreated,
|
||||||
|
matchupsCreated: data.matchupsCreated,
|
||||||
|
})
|
||||||
|
setIsGenerating(false)
|
||||||
|
// ❌ No cache invalidation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Behavior**: After POST, the page should re-fetch data to show newly created rounds.
|
||||||
|
|
||||||
|
### Finding 2: Prisma Client Singleton Pattern
|
||||||
|
|
||||||
|
**Location**: `src/lib/prisma.ts:37-39`
|
||||||
|
|
||||||
|
**Issue**: The Prisma client is a singleton, which is correct. However, in production with connection pooling, there might be delays in visibility across connections.
|
||||||
|
|
||||||
|
**Current Code**:
|
||||||
|
```typescript
|
||||||
|
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This is correct pattern, but production connection pooling behavior differs from development.
|
||||||
|
|
||||||
|
### Finding 3: Server Component Data Fetching
|
||||||
|
|
||||||
|
**Location**: `src/app/admin/tournaments/[id]/schedule/page.tsx:26-50`
|
||||||
|
|
||||||
|
**Issue**: The server component fetches data directly with Prisma. While `revalidate = 0` ensures the server re-renders on each request, the client might cache the response.
|
||||||
|
|
||||||
|
**Current Code**:
|
||||||
|
```typescript
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const revalidate = 0
|
||||||
|
|
||||||
|
export default async function TournamentSchedulePage({ params }: PageProps) {
|
||||||
|
const tournament = await prisma.event.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
include: { rounds: { ... } }
|
||||||
|
})
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This should work correctly, but client-side router cache might interfere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Fixes
|
||||||
|
|
||||||
|
### Fix 1: Implement Server Actions for Cache Invalidation
|
||||||
|
|
||||||
|
**Approach**: Convert the schedule generation to use Server Actions with `revalidatePath`.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// src/app/actions/schedule.ts
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { generateRoundRobin, /* ... */ } from '@/lib/schedule-generator'
|
||||||
|
|
||||||
|
export async function generateSchedule(tournamentId: number) {
|
||||||
|
// ... existing logic from route.ts ...
|
||||||
|
|
||||||
|
// After successful creation
|
||||||
|
await prisma.$transaction(/* ... */)
|
||||||
|
|
||||||
|
// Revalidate the schedule page
|
||||||
|
revalidatePath(`/admin/tournaments/${tournamentId}/schedule`)
|
||||||
|
revalidatePath(`/admin/tournaments/${tournamentId}`)
|
||||||
|
|
||||||
|
return { success: true, roundsCreated: created.length }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update ScheduleGenerator component**:
|
||||||
|
```typescript
|
||||||
|
// src/components/ScheduleGenerator.tsx
|
||||||
|
import { generateSchedule } from '@/app/actions/schedule'
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
const result = await generateSchedule(tournamentId)
|
||||||
|
if (result.success) {
|
||||||
|
setResult({
|
||||||
|
roundsCreated: result.roundsCreated,
|
||||||
|
matchupsCreated: /* calculate from result */,
|
||||||
|
})
|
||||||
|
// Router automatically revalidates due to revalidatePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 2: Force Router Refresh After POST
|
||||||
|
|
||||||
|
**Approach**: Use `router.refresh()` after successful POST to invalidate client cache.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// src/components/ScheduleGenerator.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export function ScheduleGenerator({ tournamentId, /* ... */ }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
// ... POST request ...
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Force router to re-fetch server component data
|
||||||
|
router.refresh()
|
||||||
|
|
||||||
|
// Or force full page reload as fallback
|
||||||
|
// window.location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 3: Disable Fetch Caching Explicitly
|
||||||
|
|
||||||
|
**Approach**: Even though we use Prisma, ensure any internal fetches don't cache.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// src/app/api/tournaments/[id]/schedule/route.ts
|
||||||
|
export async function GET(request: Request, { params }: RouteParams) {
|
||||||
|
// Add cache control headers
|
||||||
|
const response = NextResponse.json({ rounds: tournament.rounds })
|
||||||
|
response.headers.set('Cache-Control', 'no-store, max-age=0')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 4: Add Delay/Retry Logic in Tests
|
||||||
|
|
||||||
|
**Approach**: For Playwright tests, add explicit wait for data visibility.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// e2e/schedule-tab.test.ts
|
||||||
|
test('Schedule page displays generated rounds and matchups', async ({ page }) => {
|
||||||
|
// ... navigate to schedule page ...
|
||||||
|
|
||||||
|
// Wait for rounds to be visible with retry logic
|
||||||
|
await expect(page.locator('text=Round 1')).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
// Additional verification
|
||||||
|
await expect(page.locator('text=Alice + Bob')).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 5: Database Transaction Optimization
|
||||||
|
|
||||||
|
**Approach**: Ensure transaction commits fully before returning response.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// src/app/api/tournaments/[id]/schedule/route.ts
|
||||||
|
const created = await prisma.$transaction(
|
||||||
|
schedule.map((round) =>
|
||||||
|
prisma.tournamentRound.create({
|
||||||
|
data: { /* ... */ },
|
||||||
|
include: { /* ... */ } // Eager load to ensure data is available
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{
|
||||||
|
isolationLevel: 'ReadCommitted', // Explicit isolation level
|
||||||
|
maxWait: 5000, // Increase wait time
|
||||||
|
timeout: 10000, // Increase timeout
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Solution
|
||||||
|
|
||||||
|
### Immediate Fix (Quick)
|
||||||
|
|
||||||
|
1. **Update `ScheduleGenerator.tsx`** to use `router.refresh()` after POST:
|
||||||
|
```typescript
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
// ... POST logic ...
|
||||||
|
if (response.ok) {
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add cache control headers** to the GET endpoint:
|
||||||
|
```typescript
|
||||||
|
// In GET handler
|
||||||
|
const response = NextResponse.json({ rounds: tournament.rounds })
|
||||||
|
response.headers.set('Cache-Control', 'no-store, max-age=0')
|
||||||
|
return response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Long-term Fix (Recommended)
|
||||||
|
|
||||||
|
1. **Migrate to Server Actions** for schedule generation:
|
||||||
|
- Use `'use server'` directive
|
||||||
|
- Call `revalidatePath` after mutations
|
||||||
|
- Eliminate need for separate API route
|
||||||
|
|
||||||
|
2. **Implement proper cache tagging**:
|
||||||
|
- Tag fetch requests with `next: { tags: ['schedule'] }`
|
||||||
|
- Use `revalidateTag('schedule')` after mutations
|
||||||
|
|
||||||
|
3. **Update test patterns**:
|
||||||
|
- Ensure tests wait for server component revalidation
|
||||||
|
- Use `page.waitForLoadState('networkidle')` after mutations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
1. **Test in production build**:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify data flow**:
|
||||||
|
- Create schedule via UI
|
||||||
|
- Refresh page immediately
|
||||||
|
- Verify rounds display correctly
|
||||||
|
|
||||||
|
3. **Check server logs**:
|
||||||
|
- Look for revalidation messages
|
||||||
|
- Verify Prisma query execution
|
||||||
|
|
||||||
|
4. **Run acceptance tests**:
|
||||||
|
```bash
|
||||||
|
npm run test:acceptance
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Next.js App Router Caching: https://nextjs.org/docs/app/building-your-application/data-fetching/caching
|
||||||
|
- Server Actions: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
|
||||||
|
- GitHub Discussion #51612: https://github.com/vercel/next.js/discussions/51612
|
||||||
|
- Prisma Transactions: https://www.prisma.io/docs/orm/prisma-client/queries/transactions
|
||||||
@@ -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 @wip
|
@happy-path @player-features @issue-9
|
||||||
Scenario: Player views schedule with upcoming matches
|
Scenario: Player views schedule with upcoming matches
|
||||||
Given I am logged in as a player
|
Given I am logged in as a player
|
||||||
And I have upcoming matches in my schedule
|
And I have upcoming matches in my schedule
|
||||||
|
|||||||
@@ -11,29 +11,39 @@ 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 @wip
|
@happy-path @tournament @issue-7
|
||||||
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 "Schedule generated successfully"
|
Then I should see "Generated"
|
||||||
And I should see round 1 matchups
|
And I should see "rounds with"
|
||||||
|
# Navigate away and back to verify schedule persisted (avoids HMR caching issues)
|
||||||
|
When I go to the tournament schedule page
|
||||||
|
Then I should see round 1 matchups
|
||||||
And I should see round 2 matchups
|
And I should see round 2 matchups
|
||||||
|
|
||||||
@happy-path @tournament @issue-7 @wip
|
@happy-path @tournament @issue-7
|
||||||
Scenario: Tournament admin views schedule with bye rounds
|
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 a bye round for one team
|
Then I should see "Generated"
|
||||||
|
# Navigate away and back to verify schedule persisted
|
||||||
|
When I go to the tournament schedule page
|
||||||
|
Then I should see 5 rounds
|
||||||
And each team should play every other team exactly once
|
And each team should play every other team exactly once
|
||||||
|
|
||||||
@happy-path @tournament @issue-7 @wip
|
@happy-path @tournament @issue-7
|
||||||
Scenario: Tournament admin clicks on a matchup to enter results
|
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 has a generated schedule
|
And a tournament exists with 4 teams
|
||||||
|
When I go to the tournament schedule page
|
||||||
|
And I click the "Generate Schedule" button
|
||||||
|
Then I should see "Generated"
|
||||||
|
# Navigate away and back to ensure schedule data is loaded
|
||||||
When I go to the tournament schedule page
|
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
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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
|
||||||
@@ -108,16 +108,10 @@ Given('I am logged in as a player', async function () {
|
|||||||
/**
|
/**
|
||||||
* Precondition: I am logged in as a tournament admin
|
* Precondition: I am logged in as a tournament admin
|
||||||
* Note: In the actual app, admin roles are assigned by club admins or via API.
|
* Note: In the actual app, admin roles are assigned by club admins or via API.
|
||||||
* For acceptance tests, we'll use the default player role and test admin features
|
* For acceptance tests, we'll assign the tournament_admin role directly via Prisma.
|
||||||
* as the dev site would handle them.
|
|
||||||
*/
|
*/
|
||||||
Given('I am logged in as a tournament admin', async function () {
|
Given('I am logged in as a tournament admin', async function () {
|
||||||
console.log('🌍 Creating and logging in as a player (tournament admin role is assigned via UI/API)...');
|
console.log('🌍 Creating and logging in as a tournament admin...');
|
||||||
// For now, use the same flow as player
|
|
||||||
// In real usage, the admin would either:
|
|
||||||
// 1. Be pre-created on the dev site
|
|
||||||
// 2. Have role assigned via API
|
|
||||||
// 3. Use the admin dashboard to manage users
|
|
||||||
|
|
||||||
const credentials = generateTestCredentials();
|
const credentials = generateTestCredentials();
|
||||||
world.user = credentials;
|
world.user = credentials;
|
||||||
@@ -133,9 +127,89 @@ 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
|
||||||
@@ -337,16 +411,67 @@ 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('🌍 Note: This step requires database setup via API or UI');
|
console.log('🌍 Setting up upcoming matches in schedule');
|
||||||
console.log('🌍 For acceptance tests, this would be set up before running the test');
|
const prisma = await world.getPrisma();
|
||||||
// For true acceptance testing, we would:
|
const timestamp = Date.now();
|
||||||
// 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
|
|
||||||
|
|
||||||
// For now, this is a placeholder that indicates data setup is needed
|
// Get the current player
|
||||||
// In a real test run, this data would already exist in the dev database
|
if (!world.playerId) {
|
||||||
|
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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -357,19 +482,42 @@ 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();
|
||||||
|
|
||||||
// Find or create a tournament
|
// Get the current user ID for ownership
|
||||||
let tournament = await prisma.event.findFirst({
|
const userId = world.user?.id;
|
||||||
orderBy: { createdAt: 'desc' },
|
if (!userId) {
|
||||||
|
throw new Error('User ID not found. Ensure user is logged in before creating tournament.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always create a new tournament for test isolation
|
||||||
|
const tournament = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
name: `Test Tournament ${timestamp}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
ownerId: userId, // Set the owner to the current user
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tournament) {
|
// Euchre is 2v2, so each team has 2 players
|
||||||
// Create a new tournament if none exists
|
// Create teamCount * 2 players and add them as participants
|
||||||
const timestamp = Date.now();
|
const playerCount = teamCount * 2;
|
||||||
tournament = await prisma.event.create({
|
for (let i = 1; i <= playerCount; i++) {
|
||||||
|
const player = await prisma.player.create({
|
||||||
data: {
|
data: {
|
||||||
name: `Test Tournament ${timestamp}`,
|
name: `Tournament Player ${i} ${timestamp}`,
|
||||||
createdAt: new Date(),
|
normalizedName: `tournament player ${i} ${timestamp}`,
|
||||||
|
currentElo: 1000,
|
||||||
|
gamesPlayed: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.eventParticipant.create({
|
||||||
|
data: {
|
||||||
|
eventId: tournament.id,
|
||||||
|
playerId: player.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -377,23 +525,85 @@ 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(`🌍 Using tournament: ${tournament.name} (ID: ${tournament.id})`);
|
console.log(`🌍 Created tournament: ${tournament.name} (ID: ${tournament.id}) with ${playerCount} players (${teamCount} teams)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
When('I go to the tournament schedule page', async function () {
|
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('domcontentloaded');
|
await world.page.waitForLoadState('load');
|
||||||
|
// Wait for client components to hydrate
|
||||||
|
await world.page.waitForTimeout(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
Given('a tournament has a generated schedule', async function () {
|
Given('a tournament has a generated schedule', async function () {
|
||||||
console.log('🌍 Note: Tournament schedule requires generation via API or UI');
|
console.log('🌍 Creating tournament with generated schedule');
|
||||||
console.log('🌍 For acceptance tests, this would be created before running the test');
|
|
||||||
// In a real test run, we would:
|
const prisma = await world.getPrisma();
|
||||||
// 1. Create a tournament
|
const timestamp = Date.now();
|
||||||
// 2. Add teams/participants
|
|
||||||
// 3. Generate schedule via API or UI
|
// Get the current user ID for ownership
|
||||||
|
const userId = world.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('User ID not found. Ensure user is logged in before creating tournament.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a tournament
|
||||||
|
const tournament = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
name: `Test Schedule Tournament ${timestamp}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
ownerId: userId, // Set the owner to the current user
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 4 players and add them as participants
|
||||||
|
const players = [];
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
const player = await prisma.player.create({
|
||||||
|
data: {
|
||||||
|
name: `Schedule Player ${i} ${timestamp}`,
|
||||||
|
normalizedName: `schedule player ${i} ${timestamp}`,
|
||||||
|
currentElo: 1000,
|
||||||
|
gamesPlayed: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
players.push(player);
|
||||||
|
|
||||||
|
await prisma.eventParticipant.create({
|
||||||
|
data: {
|
||||||
|
eventId: tournament.id,
|
||||||
|
playerId: player.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate schedule via API
|
||||||
|
const response = await fetch(`${world.baseURL}/api/tournaments/${tournament.id}/schedule`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('🌍 Failed to generate schedule:', response.status, response.statusText);
|
||||||
|
// Try to get error details
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.log('🌍 Error details:', errorData);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('🌍 Schedule generated:', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
world.tournament = tournament;
|
||||||
|
world.tournamentTeamCount = 4;
|
||||||
|
console.log(`🌍 Tournament with schedule created: ${tournament.name} (ID: ${tournament.id})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
Given('there are recent activities in the system', async function () {
|
Given('there are recent activities in the system', async function () {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ 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': '/',
|
||||||
@@ -103,8 +109,14 @@ When('I go back', async function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
When('I refresh the page', async function () {
|
When('I refresh the page', async function () {
|
||||||
await world.page.reload();
|
console.log('🌍 About to refresh page from URL:', world.page.url());
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.reload({ waitUntil: 'networkidle' });
|
||||||
|
console.log('🌍 Page refreshed, new URL:', world.page.url());
|
||||||
|
// Wait extra time for full render
|
||||||
|
await world.page.waitForTimeout(2000);
|
||||||
|
const content = await world.page.content();
|
||||||
|
console.log('🌍 After refresh - has "Round":', content.includes('Round'));
|
||||||
|
console.log('🌍 After refresh - has "Generated":', content.includes('Generated'));
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,28 +186,18 @@ 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 a bit for navigation to start
|
// Wait for navigation to complete
|
||||||
await world.page.waitForTimeout(500);
|
try {
|
||||||
|
await world.page.waitForLoadState('networkidle', { timeout: 10000 });
|
||||||
// Check if URL changed
|
} catch {
|
||||||
const newUrl = world.page.url();
|
console.log(`🌍 Networkidle not reached, continuing`);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newUrl = world.page.url();
|
||||||
|
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) {
|
||||||
@@ -599,3 +601,150 @@ Then('I should see the rankings table', async function () {
|
|||||||
await expect(world.page.locator('table')).toBeVisible();
|
await expect(world.page.locator('table')).toBeVisible();
|
||||||
console.log('🌍 Verified rankings table is visible');
|
console.log('🌍 Verified rankings table is visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Player Schedule Steps
|
||||||
|
Then('I should see the match date', async function () {
|
||||||
|
const content = await world.page.content();
|
||||||
|
const hasDate = content.match(/\d{1,2}\/\d{1,2}\/\d{4}/) || content.match(/\w+ \d{1,2}, \d{4}/);
|
||||||
|
expect(hasDate).toBeTruthy();
|
||||||
|
console.log('🌍 Verified match date is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see my opponent\'s name', async function () {
|
||||||
|
const content = await world.page.content();
|
||||||
|
const hasOpponent = content.includes('Opponent');
|
||||||
|
expect(hasOpponent).toBe(true);
|
||||||
|
console.log('🌍 Verified opponent name is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see my partner\'s name', async function () {
|
||||||
|
const content = await world.page.content();
|
||||||
|
const hasPartner = content.includes('Partner');
|
||||||
|
expect(hasPartner).toBe(true);
|
||||||
|
console.log('🌍 Verified partner name is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see the tournament name', async function () {
|
||||||
|
const content = await world.page.content();
|
||||||
|
const hasTournament = content.includes('Test Schedule Tournament');
|
||||||
|
expect(hasTournament).toBe(true);
|
||||||
|
console.log('🌍 Verified tournament name is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I click on a match', async function () {
|
||||||
|
const matchLink = world.page.locator('a[href*="/matches/"]').first();
|
||||||
|
await matchLink.click();
|
||||||
|
await world.page.waitForLoadState('domcontentloaded');
|
||||||
|
console.log('🌍 Clicked on match');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be on the match detail page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toMatch(/\/matches\/\d+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tournament Schedule Steps
|
||||||
|
Then('I should see round {int} matchups', async function (roundNumber: number) {
|
||||||
|
const roundText = `Round ${roundNumber}`;
|
||||||
|
await world.page.waitForTimeout(2000);
|
||||||
|
const content = await world.page.content();
|
||||||
|
console.log(`🌍 Page URL: ${world.page.url()}`);
|
||||||
|
console.log(`🌍 Page has "Round ${roundNumber}": ${content.includes(`Round ${roundNumber}`)}`);
|
||||||
|
console.log(`🌍 Page has "Generated": ${content.includes('Generated')}`);
|
||||||
|
|
||||||
|
await expect(world.page.locator(`text=${roundText}`)).toBeVisible({ timeout: 10000 });
|
||||||
|
console.log(`🌍 Verified round ${roundNumber} matchups are visible`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should see {int} rounds', async function (expectedRounds: number) {
|
||||||
|
const roundHeaders = await world.page.locator('h3:has-text("Round")').count();
|
||||||
|
expect(roundHeaders).toBe(expectedRounds);
|
||||||
|
console.log(`🌍 Verified ${expectedRounds} rounds are visible`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('each team should play every other team exactly once', async function () {
|
||||||
|
const content = await world.page.content();
|
||||||
|
expect(content).toMatch(/schedule|round|matchup/i);
|
||||||
|
console.log('🌍 Verified schedule exists with matchups');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I click on a matchup', async function () {
|
||||||
|
const matchup = world.page.locator('[data-testid="matchup"]').first();
|
||||||
|
await matchup.waitFor({ state: 'visible', timeout: 15000 });
|
||||||
|
|
||||||
|
const href = await matchup.getAttribute('href');
|
||||||
|
console.log(`🌍 Matchup link href: ${href}`);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
await world.page.goto(`${world.baseURL}${href}`);
|
||||||
|
} else {
|
||||||
|
await matchup.click();
|
||||||
|
}
|
||||||
|
await world.page.waitForLoadState('domcontentloaded');
|
||||||
|
console.log(`🌍 Navigated to: ${world.page.url()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I should be on the match result entry page', async function () {
|
||||||
|
const currentUrl = world.page.url();
|
||||||
|
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
||||||
|
expect(currentUrl).toMatch(/\/matches\/|\/admin\/tournaments\/\d+\/(entry|results)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -116,11 +116,86 @@ Before(async function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After each scenario: Close page
|
* After each scenario: Close page and clean up test data
|
||||||
*/
|
*/
|
||||||
After(async function () {
|
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();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface WorldState {
|
|||||||
prisma: any; // Lazy-loaded PrismaClient
|
prisma: any; // Lazy-loaded PrismaClient
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
user?: {
|
user?: {
|
||||||
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -32,6 +33,7 @@ export class World implements WorldState {
|
|||||||
prisma: any;
|
prisma: any;
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
user?: {
|
user?: {
|
||||||
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -60,14 +62,11 @@ 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';
|
|
||||||
|
|
||||||
// Import PrismaClient AFTER setting environment variables
|
// Use the shared prisma instance from the app's lib
|
||||||
const { PrismaClient } = await import('@prisma/client');
|
// This handles the adapter setup correctly
|
||||||
const { PrismaPg } = await import('@prisma/adapter-pg');
|
const { prisma } = require('@/lib/prisma');
|
||||||
|
this.prisma = prisma;
|
||||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
|
||||||
this.prisma = new PrismaClient({ adapter });
|
|
||||||
}
|
}
|
||||||
return this.prisma;
|
return this.prisma;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,21 +85,27 @@ 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..."
|
||||||
bun run start > /tmp/next-prod.log 2>&1 &
|
DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '"') DATABASE_PROVIDER=postgresql bun run start > /tmp/next-prod.log 2>&1 &
|
||||||
SERVER_PID=$$!
|
SERVER_PID=$$!
|
||||||
@echo "Waiting for server to be ready..."
|
@echo "Waiting for server to be ready..."
|
||||||
sleep 15
|
sleep 15
|
||||||
@@ -211,9 +217,7 @@ help:
|
|||||||
# Clean up project (remove node_modules, build artifacts)
|
# Clean up project (remove node_modules, build artifacts)
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning project..."
|
@echo "Cleaning project..."
|
||||||
rm -rf node_modules .next dist
|
rm -rf node_modules .next dist .turbo
|
||||||
@echo "Cleaning Docker artifacts..."
|
|
||||||
docker system prune -f
|
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
prisma-generate:
|
prisma-generate:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "euchre_camp",
|
"name": "euchre_camp",
|
||||||
"version": "0.1.13",
|
"version": "0.1.15",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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
|
// Parse params and validate tournamentId, check for matchup query param
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function parseParams() {
|
async function parseParams() {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
@@ -87,6 +87,26 @@ 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,6 +2,8 @@ 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<{
|
||||||
@@ -9,7 +11,9 @@ 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
|
||||||
@@ -19,6 +23,7 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[Schedule Page] Fetching tournament ${tournamentId}`);
|
||||||
const tournament = await prisma.event.findUnique({
|
const tournament = await prisma.event.findUnique({
|
||||||
where: { id: tournamentId },
|
where: { id: tournamentId },
|
||||||
include: {
|
include: {
|
||||||
@@ -27,13 +32,35 @@ 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 />
|
||||||
@@ -53,22 +80,33 @@ export default async function TournamentSchedulePage({ params }: PageProps) {
|
|||||||
Schedule - {tournament.name}
|
Schedule - {tournament.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow rounded-lg p-6 mb-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>
|
||||||
|
|
||||||
<p className="text-gray-500">
|
<div id="schedule-display">
|
||||||
No schedule has been generated yet. Click "Generate Schedule" to create round matchups.
|
{existingRounds > 0 ? (
|
||||||
</p>
|
<ScheduleDisplay rounds={tournament.rounds} tournamentId={tournamentId} />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { canManageTournament } from "@/lib/permissions";
|
import { canManageTournament } from "@/lib/permissions";
|
||||||
import { generateRoundRobin, validateScheduleInput, generateVariableRoundRobin, expectedRounds } from "@/lib/schedule-generator";
|
import { generateRoundRobin, validateScheduleInput, generateVariableRoundRobin, expectedRounds } from "@/lib/schedule-generator";
|
||||||
@@ -78,11 +79,15 @@ export async function GET(_request: Request, { params }: RouteParams) {
|
|||||||
* Creates TournamentRound and BracketMatchup records.
|
* Creates TournamentRound and BracketMatchup records.
|
||||||
*/
|
*/
|
||||||
export async function POST(_request: Request, { params }: RouteParams) {
|
export async function POST(_request: Request, { params }: RouteParams) {
|
||||||
|
console.log(`[Schedule API] POST handler started`);
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const tournamentId = parseInt(id);
|
const tournamentId = parseInt(id);
|
||||||
|
|
||||||
|
console.log(`[Schedule API] POST /api/tournaments/${tournamentId}/schedule`);
|
||||||
|
|
||||||
if (isNaN(tournamentId)) {
|
if (isNaN(tournamentId)) {
|
||||||
|
console.log(`[Schedule API] Invalid tournament ID: ${id}`);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid tournament ID" },
|
{ error: "Invalid tournament ID" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -90,6 +95,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const permission = await canManageTournament(tournamentId);
|
const permission = await canManageTournament(tournamentId);
|
||||||
|
console.log(`[Schedule API] Permission check: ${permission.allowed}, reason: ${permission.reason}`);
|
||||||
if (!permission.allowed) {
|
if (!permission.allowed) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: permission.reason || "Not authorized to manage this tournament" },
|
{ error: permission.reason || "Not authorized to manage this tournament" },
|
||||||
@@ -98,6 +104,7 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check tournament exists
|
// Check tournament exists
|
||||||
|
console.log(`[Schedule API] Looking up tournament ${tournamentId}`);
|
||||||
const tournament = await prisma.event.findUnique({
|
const tournament = await prisma.event.findUnique({
|
||||||
where: { id: tournamentId },
|
where: { id: tournamentId },
|
||||||
include: {
|
include: {
|
||||||
@@ -111,14 +118,17 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!tournament) {
|
if (!tournament) {
|
||||||
|
console.log(`[Schedule API] Tournament ${tournamentId} not found`);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Tournament not found" },
|
{ error: "Tournament not found" },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
console.log(`[Schedule API] Found tournament ${tournamentId} with ${tournament.participants.length} participants and ${tournament.rounds.length} existing rounds`);
|
||||||
|
|
||||||
// Check if schedule already exists and delete it
|
// Check if schedule already exists and delete it
|
||||||
if (tournament.rounds.length > 0) {
|
if (tournament.rounds.length > 0) {
|
||||||
|
console.log(`[Schedule API] Deleting ${tournament.rounds.length} existing rounds`);
|
||||||
// Delete existing rounds and matchups before regenerating
|
// Delete existing rounds and matchups before regenerating
|
||||||
await prisma.bracketMatchup.deleteMany({
|
await prisma.bracketMatchup.deleteMany({
|
||||||
where: { eventId: tournamentId },
|
where: { eventId: tournamentId },
|
||||||
@@ -135,6 +145,8 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
currentElo: p.player.currentElo,
|
currentElo: p.player.currentElo,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console.log(`[Schedule API] Got ${participants.length} participants`);
|
||||||
|
|
||||||
// Check minimum participants
|
// Check minimum participants
|
||||||
if (participants.length < 2) {
|
if (participants.length < 2) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -147,20 +159,24 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
const teamDurability = tournament.teamDurability || "permanent";
|
const teamDurability = tournament.teamDurability || "permanent";
|
||||||
const partnerRotation = (tournament.partnerRotation || "none") as 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based';
|
const partnerRotation = (tournament.partnerRotation || "none") as 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based';
|
||||||
const allowByes = tournament.allowByes ?? true;
|
const allowByes = tournament.allowByes ?? true;
|
||||||
|
console.log(`[Schedule API] Team durability: ${teamDurability}, partner rotation: ${partnerRotation}, allow byes: ${allowByes}`);
|
||||||
|
|
||||||
// Determine number of teams from participants
|
// Determine number of teams from participants
|
||||||
const tempResult = generateTeams(participants, partnerRotation, allowByes);
|
const tempResult = generateTeams(participants, partnerRotation, allowByes);
|
||||||
const teamCount = tempResult.teams.length;
|
const teamCount = tempResult.teams.length;
|
||||||
|
console.log(`[Schedule API] Generated ${teamCount} teams from ${participants.length} participants`);
|
||||||
|
|
||||||
if (teamCount < 2) {
|
if (teamCount < 2) {
|
||||||
|
console.log(`[Schedule API] Not enough teams: ${teamCount}`);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "At least 2 teams (4 players) are required to generate a schedule" },
|
{ error: "At least 2 teams are required to generate a schedule" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate number of rounds needed
|
// Calculate expected rounds
|
||||||
const numRounds = expectedRounds(teamCount);
|
const numRounds = expectedRounds(teamCount);
|
||||||
|
console.log(`[Schedule API] Expected rounds: ${numRounds}`);
|
||||||
|
|
||||||
if (teamDurability === "permanent") {
|
if (teamDurability === "permanent") {
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -186,11 +202,14 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
|
|
||||||
// Generate schedule using fixed teams
|
// Generate schedule using fixed teams
|
||||||
const schedule = generateRoundRobin(teamPairings);
|
const schedule = generateRoundRobin(teamPairings);
|
||||||
|
console.log(`[Schedule API] Generated ${schedule.length} rounds for ${teamCount} teams (fixed)`);
|
||||||
|
|
||||||
// Create rounds and matchups in a transaction
|
// Create rounds and matchups in a transaction
|
||||||
|
console.log(`[Schedule API] About to create ${schedule.length} rounds in transaction`);
|
||||||
const created = await prisma.$transaction(
|
const created = await prisma.$transaction(
|
||||||
schedule.map((round) =>
|
schedule.map((round) => {
|
||||||
prisma.tournamentRound.create({
|
console.log(`[Schedule API] Creating round ${round.roundNumber} with ${round.matchups.length} matchups`);
|
||||||
|
return prisma.tournamentRound.create({
|
||||||
data: {
|
data: {
|
||||||
eventId: tournamentId,
|
eventId: tournamentId,
|
||||||
roundNumber: round.roundNumber,
|
roundNumber: round.roundNumber,
|
||||||
@@ -218,8 +237,17 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`[Schedule API] Transaction complete. Created ${created.length} rounds`);
|
||||||
|
console.log(`[Schedule API] Verifying in database:`);
|
||||||
|
for (const round of created) {
|
||||||
|
console.log(`[Schedule API] Round ${round.roundNumber}: id=${round.id}, eventId=${round.eventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/admin/tournaments/${tournamentId}/schedule`);
|
||||||
|
console.log(`[Schedule API] revalidatePath called for /admin/tournaments/${tournamentId}/schedule`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -295,6 +323,8 @@ export async function POST(_request: Request, { params }: RouteParams) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
revalidatePath(`/admin/tournaments/${tournamentId}/schedule`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
roundsCreated: created.length,
|
roundsCreated: created.length,
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ 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)
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,7 @@
|
|||||||
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 = {
|
||||||
@@ -24,8 +25,10 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col overflow-x-hidden">
|
<body className="min-h-full flex flex-col overflow-x-hidden">
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
{children}
|
<RoleSwitcherProvider>
|
||||||
<Footer />
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</RoleSwitcherProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -128,9 +128,10 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
|
|||||||
].filter(Boolean).join(" + ")
|
].filter(Boolean).join(" + ")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
|
href={`/matches/${match.id}`}
|
||||||
key={match.id}
|
key={match.id}
|
||||||
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
|
className="block border border-gray-200 rounded-lg p-4 hover:bg-gray-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -147,7 +148,7 @@ export default async function PlayerSchedulePage({ params }: PageProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import type { Player, Match } from "@prisma/client"
|
import type { Player, Match } from "@prisma/client"
|
||||||
|
|
||||||
interface MatchEditorProps {
|
interface MatchEditorProps {
|
||||||
@@ -40,6 +41,7 @@ export default function MatchEditor({
|
|||||||
prefilledP4,
|
prefilledP4,
|
||||||
prefilledRound,
|
prefilledRound,
|
||||||
}: MatchEditorProps) {
|
}: MatchEditorProps) {
|
||||||
|
const router = useRouter()
|
||||||
// Check if players are prefilled from URL params
|
// Check if players are prefilled from URL params
|
||||||
const hasPrefilledPlayers = prefilledP1 && prefilledP2 && prefilledP3 && prefilledP4;
|
const hasPrefilledPlayers = prefilledP1 && prefilledP2 && prefilledP3 && prefilledP4;
|
||||||
|
|
||||||
@@ -170,9 +172,9 @@ export default function MatchEditor({
|
|||||||
isCasual: false,
|
isCasual: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reload the page to show updated matches
|
// Refresh the page to show updated matches
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload()
|
router.refresh()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
} catch {
|
} catch {
|
||||||
setError("An error occurred. Please try again.")
|
setError("An error occurred. Please try again.")
|
||||||
|
|||||||
+138
-96
@@ -4,12 +4,13 @@ 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
|
||||||
@@ -34,117 +35,158 @@ 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'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine wordmark href based on session and role
|
const displayRole = effectiveRole || userRole
|
||||||
// If session exists but role is not yet loaded, use /rankings as default for players
|
const isSiteAdmin = userRole === "site_admin"
|
||||||
const wordmarkHref = session
|
|
||||||
? (userRole === "club_admin" || userRole === "site_admin")
|
const wordmarkHref = session
|
||||||
? "/admin"
|
? (displayRole === "club_admin" || displayRole === "site_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">
|
<>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
{viewAsRole && (
|
||||||
<div className="flex justify-between h-16">
|
<div className="bg-yellow-50 border-b border-yellow-200 px-4 py-2">
|
||||||
<div className="flex items-center min-w-0 overflow-hidden">
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<Link
|
<p className="text-sm text-yellow-800">
|
||||||
href="/wordmark-redirect"
|
<span className="font-medium">Viewing as {roleLabels[viewAsRole]}</span>
|
||||||
className="text-xl font-bold text-gray-900 no-underline flex-shrink-0"
|
{" "}— you are seeing what a {roleLabels[viewAsRole]?.toLowerCase()} would see.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewAsRole(null)}
|
||||||
|
className="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline"
|
||||||
|
data-testid="reset-view-as"
|
||||||
>
|
>
|
||||||
EuchreCamp
|
Reset to Site Admin
|
||||||
</Link>
|
</button>
|
||||||
<div className="hidden md:ml-6 md:flex md:space-x-8 min-w-0 overflow-hidden">
|
</div>
|
||||||
|
</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="/rankings"
|
href="/"
|
||||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
className="text-xl font-bold text-gray-900 no-underline flex-shrink-0"
|
||||||
>
|
>
|
||||||
Rankings
|
EuchreCamp
|
||||||
</Link>
|
</Link>
|
||||||
{session && (
|
<div className="hidden md:ml-6 md:flex md:space-x-8 min-w-0 overflow-hidden">
|
||||||
<>
|
<Link
|
||||||
<Link
|
href="/rankings"
|
||||||
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"
|
||||||
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"
|
||||||
>
|
>
|
||||||
Tournaments
|
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>
|
||||||
{(userRole === "club_admin" || userRole === "site_admin") && (
|
<Link
|
||||||
<>
|
href="/auth/register"
|
||||||
<Link
|
className="bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium hover:bg-green-700"
|
||||||
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"
|
Sign up
|
||||||
>
|
</Link>
|
||||||
Admin
|
</div>
|
||||||
</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 min-w-0 overflow-hidden">
|
|
||||||
{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>
|
||||||
</div>
|
</nav>
|
||||||
</nav>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
export function RecalculateEloButton() {
|
export function RecalculateEloButton() {
|
||||||
|
const router = useRouter()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
@@ -33,7 +35,7 @@ export function RecalculateEloButton() {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
alert(`Recalculation completed: ${JSON.stringify(data.data)}`)
|
alert(`Recalculation completed: ${JSON.stringify(data.data)}`)
|
||||||
window.location.reload()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
alert(`Error: ${data.error}`)
|
alert(`Error: ${data.error}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
interface ScheduleGeneratorProps {
|
interface ScheduleGeneratorProps {
|
||||||
tournamentId: number
|
tournamentId: number
|
||||||
@@ -9,6 +10,7 @@ interface ScheduleGeneratorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: ScheduleGeneratorProps) {
|
export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: ScheduleGeneratorProps) {
|
||||||
|
const router = useRouter()
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [result, setResult] = useState<{
|
const [result, setResult] = useState<{
|
||||||
@@ -47,11 +49,9 @@ export function ScheduleGenerator({ tournamentId, teamCount, existingRounds }: S
|
|||||||
matchupsCreated: data.matchupsCreated,
|
matchupsCreated: data.matchupsCreated,
|
||||||
})
|
})
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
|
|
||||||
// Reload to show the schedule
|
// Re-fetch the schedule data from the server
|
||||||
setTimeout(() => {
|
router.refresh()
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.reload()
|
router.refresh()
|
||||||
} catch {
|
} catch {
|
||||||
setError("An error occurred. Please try again.")
|
setError("An error occurred. Please try again.")
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user