52 Commits

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

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

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

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

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

All 19 tests pass.

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

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

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

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

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

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

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

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

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