diff --git a/.agent/rules/website.md b/.agent/rules/website.md new file mode 100644 index 00000000..cfbe3b5e --- /dev/null +++ b/.agent/rules/website.md @@ -0,0 +1,104 @@ +--- +trigger: always_on +--- + +# Agent Guide - Website Relaunch + +This repository is a monorepo containing multiple services. Please follow these guidelines when working on this codebase. + +## Project Structure + +- `api/` - NestJS API service (TypeScript) +- `frontend/` - Next.js frontend application (TypeScript) +- `github-service/` - NestJS service for GitHub integration (TypeScript) +- `k8s-service/` - Kubernetes management service (Go) + +## 1. Build, Lint, and Test Commands + +### General + +- Package Manager: `pnpm` is used for JavaScript/TypeScript projects. +- Go: Standard Go toolchain (1.23+) and `make`. + +### `api/` & `github-service/` (NestJS) + +- **Build:** `pnpm build` (Runs `nest build`) +- **Lint:** `pnpm lint` (Runs `eslint`) +- **Format:** `pnpm format` (Runs `prettier`) +- **Run Dev:** `pnpm start:dev` +- **Test:** `pnpm test` (Runs `jest`) +- **Run Single Test:** + + ```bash + # Run a specific test file + npx jest src/path/to/file.spec.ts + + # Run a specific test case by name + pnpm test -- -t "should do something" + ``` + +### `frontend/` (Next.js) + +- **Build:** `pnpm build` (Runs `next build`) +- **Dev:** `pnpm dev` +- **Lint:** `pnpm lint` +- **Run Single Test:** (Assuming standard Jest/Vitest setup if present, otherwise rely on linting/build) + ```bash + pnpm test -- path/to/file + ``` + +### `k8s-service/` (Go) + +- **Build:** `make build` (compiles to `bin/server`) +- **Run:** `make run` +- **Test:** `make test` (Runs `go test -v ./...`) +- **Run Single Test:** + + ```bash + # Run tests in a specific package + go test -v ./internal/package_name + + # Run a specific test function + go test -v ./internal/package_name -run TestName + ``` + +## 2. Code Style & Conventions + +### TypeScript (NestJS & Next.js) + +- **Formatting:** Use Prettier. 2 spaces indentation. Double quotes for strings and imports. Semicolons required. +- **Naming:** + - Variables/Functions: `camelCase` + - Classes/Interfaces/Components: `PascalCase` + - Files: `kebab-case.ts` (NestJS conventions), `PascalCase.tsx` (React components) or `page.tsx`/`layout.tsx` (Next.js App Router). +- **Imports:** Clean and organized. Remove unused imports. +- **Typing:** Strict TypeScript. Avoid `any` where possible. Use interfaces/types for DTOs and props. +- **NestJS Specifics:** + - Use Dependency Injection via constructors. + - Use Decorators (`@Injectable()`, `@Controller()`, `@Get()`) appropriately. + - Follow `module` -> `controller` -> `service` architecture. +- **Next.js Specifics:** + - Use App Router structure (`app/`). + - Mark Client Components with `"use client"` at the top. + - Use Tailwind CSS for styling. + - **UI Components:** ONLY use `shadcn/ui` components for building UIs. Do not introduce other UI libraries or create custom components if a `shadcn` equivalent exists. Check `components/ui` or `components.json` for available components. + +### Go (`k8s-service`) + +- **Formatting:** Standard `gofmt`. +- **Project Layout:** Follows Standard Go Project Layout (`cmd/`, `internal/`, `pkg/`). +- **Error Handling:** + - Return errors as the last return value. + - Check errors immediately: `if err != nil { return err }`. + - Don't panic unless during startup. +- **Logging:** Use `zap.SugaredLogger`. +- **Web Framework:** Uses `echo`. +- **Configuration:** Uses `internal/config` and environment variables. + +## 3. General Rules for Agents + +1. **Context is King:** Always analyze the surrounding code before making changes to match the existing style. +2. **Verify Changes:** Run the lint and test commands for the specific service you are modifying before declaring the task complete. +3. **Monorepo Awareness:** Be aware of which directory you are in. Do not run `npm` commands in the root if you intend to affect a specific service; `cd` into the service directory or use `pnpm --filter`. +4. **No Blind Edits:** Use `read` to check file contents before `edit` or `write`. +5. **Paths:** Always use absolute paths for file operations. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8f53a708 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,91 @@ +# Agent Guide - Website Relaunch + +This repository is a monorepo containing multiple services. Please follow these guidelines when working on this codebase. + +## Project Structure + +- `api/` - NestJS API service (TypeScript) +- `frontend/` - Next.js frontend application (TypeScript) +- `github-service/` - NestJS service for GitHub integration (TypeScript) +- `k8s-service/` - Kubernetes management service (Go) + +## 1. Build, Lint, and Test Commands + +### General +- Package Manager: `pnpm` is used for JavaScript/TypeScript projects. +- Go: Standard Go toolchain (1.23+) and `make`. + +### `api/` & `github-service/` (NestJS) +* **Build:** `pnpm build` (Runs `nest build`) +* **Lint:** `pnpm lint` (Runs `eslint`) +* **Format:** `pnpm format` (Runs `prettier`) +* **Run Dev:** `pnpm start:dev` +* **Test:** `pnpm test` (Runs `jest`) +* **Run Single Test:** + ```bash + # Run a specific test file + npx jest src/path/to/file.spec.ts + + # Run a specific test case by name + pnpm test -- -t "should do something" + ``` + +### `frontend/` (Next.js) +* **Build:** `pnpm build` (Runs `next build`) +* **Dev:** `pnpm dev` +* **Lint:** `pnpm lint` +* **Run Single Test:** (Assuming standard Jest/Vitest setup if present, otherwise rely on linting/build) + ```bash + pnpm test -- path/to/file + ``` + +### `k8s-service/` (Go) +* **Build:** `make build` (compiles to `bin/server`) +* **Run:** `make run` +* **Test:** `make test` (Runs `go test -v ./...`) +* **Run Single Test:** + ```bash + # Run tests in a specific package + go test -v ./internal/package_name + + # Run a specific test function + go test -v ./internal/package_name -run TestName + ``` + +## 2. Code Style & Conventions + +### TypeScript (NestJS & Next.js) +* **Formatting:** Use Prettier. 2 spaces indentation. Double quotes for strings and imports. Semicolons required. +* **Naming:** + * Variables/Functions: `camelCase` + * Classes/Interfaces/Components: `PascalCase` + * Files: `kebab-case.ts` (NestJS conventions), `PascalCase.tsx` (React components) or `page.tsx`/`layout.tsx` (Next.js App Router). +* **Imports:** Clean and organized. Remove unused imports. +* **Typing:** Strict TypeScript. Avoid `any` where possible. Use interfaces/types for DTOs and props. +* **NestJS Specifics:** + * Use Dependency Injection via constructors. + * Use Decorators (`@Injectable()`, `@Controller()`, `@Get()`) appropriately. + * Follow `module` -> `controller` -> `service` architecture. +* **Next.js Specifics:** + * Use App Router structure (`app/`). + * Mark Client Components with `"use client"` at the top. + * Use Tailwind CSS for styling. + * **UI Components:** ONLY use `shadcn/ui` components for building UIs. Do not introduce other UI libraries or create custom components if a `shadcn` equivalent exists. Check `components/ui` or `components.json` for available components. + +### Go (`k8s-service`) +* **Formatting:** Standard `gofmt`. +* **Project Layout:** Follows Standard Go Project Layout (`cmd/`, `internal/`, `pkg/`). +* **Error Handling:** + * Return errors as the last return value. + * Check errors immediately: `if err != nil { return err }`. + * Don't panic unless during startup. +* **Logging:** Use `zap.SugaredLogger`. +* **Web Framework:** Uses `echo`. +* **Configuration:** Uses `internal/config` and environment variables. + +## 3. General Rules for Agents +1. **Context is King:** Always analyze the surrounding code before making changes to match the existing style. +2. **Verify Changes:** Run the lint and test commands for the specific service you are modifying before declaring the task complete. +3. **Monorepo Awareness:** Be aware of which directory you are in. Do not run `npm` commands in the root if you intend to affect a specific service; `cd` into the service directory or use `pnpm --filter`. +4. **No Blind Edits:** Use `read` to check file contents before `edit` or `write`. +5. **Paths:** Always use absolute paths for file operations. diff --git a/TOURNAMENT_LOGIC.md b/TOURNAMENT_LOGIC.md new file mode 100644 index 00000000..884edb51 --- /dev/null +++ b/TOURNAMENT_LOGIC.md @@ -0,0 +1,67 @@ +# Tournament Logic Documentation + +This document describes the scoring, advancement, and seeding logic used in the tournament system. + +## 1. Swiss Phase (Group Phase) + +The Swiss Phase is the initial part of the event where teams play a fixed number of rounds against opponents with similar records. + +### Scoring and Ranking + +- **Primary Score (`score`):** Teams receive **1 point** for each match win. +- **Secondary Score (Buchholz Points):** Used as a tie-breaker. A team's Buchholz points are calculated as the **sum of the current scores of all opponents that the team has defeated**. + - _Note: This is a modified Buchholz system that specifically rewards wins against stronger opponents._ +- **Ranking Order:** Teams are ranked by: + 1. `score` (Descending) + 2. `buchholzPoints` (Descending) + +### Round Generation + +- **Number of Rounds:** The maximum number of Swiss rounds is calculated as `ceil(log2(N))`, where `N` is the number of teams. +- **Pairing Algorithm:** The system uses standard Swiss pairing, attempting to match teams with identical or similar scores while avoiding Repeat Matches (teams cannot play the same opponent twice in the Swiss phase). +- **Byes:** If there is an odd number of teams, one team receives a "Bye" each round. A Bye counts as a win (1 point). No team can receive more than one Bye during the Swiss phase. + +--- + +## 2. Transition to Elimination Phase + +Once the maximum number of Swiss rounds is completed, the system determines which teams advance to the final tournament bracket. + +### Advancement Criteria + +The system automatically advances the top teams based on the Swiss rankings. The number of teams selected is the **highest power of two** that is less than or equal to the total number of teams ($2^{\lfloor \log_2(N) \rfloor}$). + +- _Example 1:_ If there are 12 teams, the top **8** teams advance. +- _Example 2:_ If there are 16 teams, all **16** teams advance. +- _Example 3:_ If there are 20 teams, the top **16** teams advance. + +--- + +## 3. Elimination Phase (Tournament Phase) + +The Elimination Phase is a single-elimination bracket. + +### Initial Layout (Seeding) + +The initial matches (Round 0) are generated based on the final Swiss rankings (Seed 0 is the 1st ranked team, Seed 1 is the 2nd, etc.). + +The seeding follows a "Snake" pairing logic to ensure high-seeded teams are distributed across the bracket and don't meet until later rounds. For $N$ advancing teams, the pairings are: + +| Match | Pairing (Seeds) | Sum of Seeds | +| :---------- | :------------------- | :----------- | +| Match 1 | Seed 0 vs Seed $N-1$ | $N-1$ | +| Match 2 | Seed 2 vs Seed $N-3$ | $N-1$ | +| Match 3 | Seed 4 vs Seed $N-5$ | $N-1$ | +| ... | ... | ... | +| Match $N/2$ | Seed $N-2$ vs Seed 1 | $N-1$ | + +**Example for 8 Teams:** + +- Match 1: Seed 0 vs Seed 7 (1st vs 8th) +- Match 2: Seed 2 vs Seed 5 (3rd vs 6th) +- Match 3: Seed 4 vs Seed 3 (5th vs 4th) +- Match 4: Seed 6 vs Seed 1 (7th vs 2nd) + +### Bracket Progression + +The winners of Match 1 and Match 2 meet in the next round. The winners of Match 3 and Match 4 meet in the next round. This ensures that the 1st and 2nd seeds (Seed 0 and Seed 1) are in opposite halves of the bracket and can only meet in the final. diff --git a/api/README.md b/api/README.md index 25f33aa5..9b1a97aa 100644 --- a/api/README.md +++ b/api/README.md @@ -6,11 +6,11 @@ Main backend API service for the CORE game website. This NestJS service serves as the primary backend, handling: -* User authentication and management -* Team and event/tournament management -* Match results and statistics -* Database operations (PostgreSQL) -* RabbitMQ microservice communication +- User authentication and management +- Team and event/tournament management +- Match results and statistics +- Database operations (PostgreSQL) +- RabbitMQ microservice communication ## Getting Started @@ -58,42 +58,47 @@ brew install pnpm ### Production -* **Build:** `pnpm build` -* **Start:** `pnpm start:prod` +- **Build:** `pnpm build` +- **Start:** `pnpm start:prod` ## Environment Variables ### Database (Required) -* `DB_HOST` - PostgreSQL host -* `DB_PORT` - PostgreSQL port -* `DB_USER` - Database username -* `DB_PASSWORD` - Database password -* `DB_NAME` - Database name -* `DB_SCHEMA` - Database schema -* `DB_URL` - Alternative database connection URL overwrites the other database connection variables -* `DB_SSL_REQUIRED` - Enable SSL connection (true/false) +- `DB_HOST` - PostgreSQL host +- `DB_PORT` - PostgreSQL port +- `DB_USER` - Database username +- `DB_PASSWORD` - Database password +- `DB_NAME` - Database name +- `DB_SCHEMA` - Database schema +- `DB_URL` - Alternative database connection URL overwrites the other database connection variables +- `DB_SSL_REQUIRED` - Enable SSL connection (true/false) ### External Services (Required) -* `RABBITMQ_URL` - RabbitMQ connection URL -* `API_SECRET_ENCRYPTION_KEY` - Key for encrypting sensitive data +- `RABBITMQ_URL` - RabbitMQ connection URL +- `API_SECRET_ENCRYPTION_KEY` - Key for encrypting sensitive data ### Optional -* `PORT` - Server port (default: 4000) -* `NODE_ENV` - Environment (development/production) +- `PORT` - Server port (default: 4000) +- `NODE_ENV` - Environment (development/production) ## Database Management ### Migrations -* **Generate:** `pnpm migration:generate migration_name` +- **Generate:** `pnpm migration:generate migration_name` Compares your current TypeScript entities with the database and automatically generates the necessary SQL (e.g., adding or removing columns). **Use this for most schema changes.** -* **Create:** `pnpm migration:create migration_name` +- **Create:** `pnpm migration:create migration_name` Creates an empty migration template. **Use this only for manual SQL changes** (e.g., seeding data, creating complex views, or custom indexes) that TypeORM cannot detect automatically. -* **Run:** `pnpm migration:run-local` (local) / `pnpm migration:run` (production) -* **Revert:** `pnpm migration:revert-local` (local) / `pnpm migration:revert` (production) +- **Run:** `pnpm migration:run-local` (local) / `pnpm migration:run` (production) +- **Revert:** `pnpm migration:revert-local` (local) / `pnpm migration:revert` (production) + +### Seeding + +- **Seed Users and Teams:** `pnpm seed:users ` + Generates 90 users and 30 teams (3 members each) and assigns them to the specified event. This is useful for testing group phases and bracket logic. ## API Documentation @@ -104,10 +109,10 @@ When running in development mode, Swagger documentation is available at: This service runs as both: -* **REST API** - HTTP endpoints for frontend communication -* **Microservice** - RabbitMQ message consumer for: - * `game_results` - Match result processing - * `github-service-results` - GitHub operation results +- **REST API** - HTTP endpoints for frontend communication +- **Microservice** - RabbitMQ message consumer for: + - `game_results` - Match result processing + - `github-service-results` - GitHub operation results ## Environment Variables diff --git a/api/package.json b/api/package.json index a1bb6bf6..81601fd1 100644 --- a/api/package.json +++ b/api/package.json @@ -25,7 +25,8 @@ "migration:generate": "sh -c 'npm run typeorm:ts -- -d ./typeOrm.config.ts migration:generate ./db/migrations/\"$0\"'", "migration:create": "sh -c 'npm run typeorm:ts -- migration:create ./db/migrations/\"$0\"'", "migration:revert": "npm run typeorm -- -d ./dist/typeOrm.config.js migration:revert", - "migration:revert-local": "npm run typeorm:ts -- -d ./typeOrm.config.ts migration:revert" + "migration:revert-local": "npm run typeorm:ts -- -d ./typeOrm.config.ts migration:revert", + "seed:users": "ts-node -r tsconfig-paths/register src/scripts/seed-users-teams.ts" }, "dependencies": { "@nestjs/common": "^11.1.13", diff --git a/api/src/match/entites/match.entity.ts b/api/src/match/entites/match.entity.ts index 298a7a9d..1fb4d5b8 100644 --- a/api/src/match/entites/match.entity.ts +++ b/api/src/match/entites/match.entity.ts @@ -38,7 +38,7 @@ export class MatchEntity { round: number; @ManyToOne(() => TeamEntity) - winner: TeamEntity; + winner: TeamEntity | null; @Column({ type: "enum", enum: MatchPhase, default: MatchPhase.SWISS }) phase: MatchPhase; diff --git a/api/src/match/match.controller.ts b/api/src/match/match.controller.ts index f6616d68..5ef9bc5d 100644 --- a/api/src/match/match.controller.ts +++ b/api/src/match/match.controller.ts @@ -4,6 +4,7 @@ import { Get, Logger, Param, + ParseBoolPipe, ParseUUIDPipe, Put, Query, @@ -21,7 +22,7 @@ export class MatchController { constructor( private readonly matchService: MatchService, private readonly eventService: EventService, - ) {} + ) { } private logger = new Logger("MatchController"); @@ -30,12 +31,12 @@ export class MatchController { getSwissMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @UserId() userId: string, - @Query("adminRevealQuery") adminRevealQuery: boolean, + @Query("adminRevealQuery", new ParseBoolPipe({ optional: true })) adminRevealQuery: boolean, ) { return this.matchService.getSwissMatches( eventId, userId, - Boolean(adminRevealQuery), + adminRevealQuery, ); } @@ -80,7 +81,7 @@ export class MatchController { getTournamentMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @UserId() userId: string, - @Query("adminRevealQuery") adminRevealQuery: boolean, + @Query("adminRevealQuery", new ParseBoolPipe({ optional: true })) adminRevealQuery: boolean, ) { return this.matchService.getTournamentMatches( eventId, @@ -169,11 +170,38 @@ export class MatchController { return this.matchService.revealAllMatchesInPhase(eventId, phase as any); } + @UseGuards(JwtAuthGuard) + @Put("cleanup-all/:eventId/:phase") + async cleanupMatches( + @Param("eventId", ParseUUIDPipe) eventId: string, + @Param("phase") phase: string, + @UserId() userId: string, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) + throw new UnauthorizedException( + "You are not authorized to cleanup matches for this event.", + ); + + return this.matchService.cleanupMatchesInPhase(eventId, phase as any); + } + + @UseGuards(JwtAuthGuard) @Get(":matchId") async getMatchById( @Param("matchId", ParseUUIDPipe) matchId: string, + @UserId() userId: string, + @Query("adminRevealQuery", new ParseBoolPipe({ optional: true })) adminRevealQuery: boolean, ): Promise { - return await this.matchService.getMatchById(matchId); + return await this.matchService.getMatchById( + matchId, + { + teams: { + event: true, + }, + }, + userId, + adminRevealQuery ?? false, + ); } @UseGuards(JwtAuthGuard) diff --git a/api/src/match/match.service.ts b/api/src/match/match.service.ts index d19f1daf..5adc3ed4 100644 --- a/api/src/match/match.service.ts +++ b/api/src/match/match.service.ts @@ -226,10 +226,59 @@ export class MatchService { if (notFinishedMatches > 0) return; - if (match.phase == MatchPhase.SWISS) - return this.processSwissFinishRound(event.id); - else if (match.phase == MatchPhase.ELIMINATION) - return this.processTournamentFinishRound(event); + const [lockPart1, lockPart2] = this.getEventLockKey(event.id); + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Use advisory lock to prevent multiple round finishes for the same event + await queryRunner.query("SELECT pg_advisory_xact_lock($1, $2)", [ + lockPart1, + lockPart2, + ]); + + // Re-check if the round is still unfinished inside the transaction + const stillNotFinished = await queryRunner.manager.count(MatchEntity, { + where: { + teams: { event: { id: event.id } }, + state: Not(MatchState.FINISHED), + phase: match.phase, + round: match.round, + }, + }); + + const currentEvent = await queryRunner.manager.findOne(EventEntity, { + where: { id: event.id }, + }); + + if ( + stillNotFinished === 0 && + currentEvent && + currentEvent.currentRound === match.round + ) { + if (match.phase == MatchPhase.SWISS) { + await this.processSwissFinishRound(event.id); + } else if (match.phase == MatchPhase.ELIMINATION) { + await this.processTournamentFinishRound(currentEvent); + } + } + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Error finishing round for event ${event.id}: ${err.message}`, + ); + } finally { + await queryRunner.release(); + } + } + + private getEventLockKey(eventId: string): [number, number] { + const hex = eventId.replace(/-/g, ""); + const part1 = parseInt(hex.slice(0, 8), 16) | 0; + const part2 = parseInt(hex.slice(8, 16), 16) | 0; + return [part1, part2]; } async processSwissFinishRound(evenId: string) { @@ -237,11 +286,11 @@ export class MatchService { teams: true, }); + const finishedRound = event.currentRound; await this.eventService.increaseEventRound(evenId); - this.logger.log( - `Event ${event.name} has finished round ${event.currentRound}.`, - ); - if (event.currentRound + 1 >= this.getMaxSwissRounds(event.teams.length)) { + this.logger.log(`Event ${event.name} has finished round ${finishedRound}.`); + + if (finishedRound + 1 >= this.getMaxSwissRounds(event.teams.length)) { this.logger.log( `Event ${event.name} has reached the maximum Swiss rounds.`, ); @@ -291,10 +340,9 @@ export class MatchService { if (finishedMatches < totalMatches) return; + const finishedRound = event.currentRound; await this.eventService.increaseEventRound(event.id); - this.logger.log( - `Event ${event.name} has finished round ${event.currentRound}.`, - ); + this.logger.log(`Event ${event.name} has finished round ${finishedRound}.`); await this.createNextTournamentMatches(event.id); } @@ -435,25 +483,21 @@ export class MatchService { teams: true, }); - if ( - event.currentRound != 0 && - (await this.matchRepository.findOneBy({ - teams: { - event: { - id: eventId, - }, + const existingMatches = await this.matchRepository.countBy({ + teams: { + event: { + id: eventId, }, - round: event.currentRound, - state: MatchState.IN_PROGRESS, // TODO need to change later to MatchState.PLANNED - phase: MatchPhase.SWISS, - })) - ) { - this.logger.error( - "Not all matches of the current round are finished. Cannot create Swiss matches.", - ); - throw new Error( - "Not all matches of the current round are finished. Cannot create Swiss matches.", + }, + round: event.currentRound, + phase: MatchPhase.SWISS, + }); + + if (existingMatches > 0) { + this.logger.warn( + `Matches for Swiss round ${event.currentRound} already exist for event ${event.name}. Skipping creation.`, ); + return []; } const maxSwissRounds = this.getMaxSwissRounds(event.teams.length); @@ -489,13 +533,12 @@ export class MatchService { } if (!match.player1 || !match.player2) { + const teamId = (match.player1 || match.player2) as string; this.logger.log( - `The team ${match.player1 || match.player2} got a bye in round ${event.currentRound} of event ${event.name}.`, - ); - await this.teamService.setHadBye( - (match.player1 || match.player2) as string, - true, + `The team ${teamId} got a bye in round ${event.currentRound} of event ${event.name}.`, ); + await this.teamService.setHadBye(teamId, true); + await this.teamService.increaseTeamScore(teamId, 1); return null; } return this.createMatch( @@ -531,6 +574,23 @@ export class MatchService { event.id, ); + const existingMatches = await this.matchRepository.countBy({ + teams: { + event: { + id: event.id, + }, + }, + round: 0, + phase: MatchPhase.ELIMINATION, + }); + + if (existingMatches > 0) { + this.logger.warn( + `Matches for tournament round 0 already exist for event ${event.name}. Skipping creation.`, + ); + return; + } + this.logger.log( `start tournament with ${highestPowerOfTwo} teams for event ${event.name}`, ); @@ -556,7 +616,7 @@ export class MatchService { } this.logger.log( - `Created next tournament matches for event ${event.name} in round ${event.currentRound + 1}.`, + `Created tournament matches for event ${event.name} in round 0.`, ); } @@ -568,6 +628,23 @@ export class MatchService { if (event.currentRound == 0) return this.createFirstTournamentMatches(event); + const existingMatches = await this.matchRepository.countBy({ + teams: { + event: { + id: eventId, + }, + }, + round: event.currentRound, + phase: MatchPhase.ELIMINATION, + }); + + if (existingMatches > 0) { + this.logger.warn( + `Matches for elimination round ${event.currentRound} already exist for event ${event.name}. Skipping creation.`, + ); + return []; + } + const lastMatches = await this.matchRepository.find({ where: { teams: { @@ -601,37 +678,36 @@ export class MatchService { } for (let i = 0; i < lastMatches.length; i += 2) { - const match = lastMatches[i]; - const nextMatch = lastMatches[i + 1]; + const match1 = lastMatches[i]; + const match2 = lastMatches[i + 1]; - if (!match.winner || !nextMatch.winner) { + if (!match1.winner || !match2.winner) { throw new Error( "One of the matches does not have a winner. Cannot create next tournament matches.", ); } - const newMatch = await this.createMatch( - [match.winner.id, nextMatch.winner.id], + // Winners play for the next round (or Final) + const finalMatch = await this.createMatch( + [match1.winner.id, match2.winner.id], event.currentRound, MatchPhase.ELIMINATION, ); - await this.startMatch(newMatch.id); - } - - if (lastMatches.length == 2) { - const losers = lastMatches - .map((match) => - match.teams.find((team) => team.id !== match.winner?.id), - ) - .filter((team): team is TeamEntity => Boolean(team)); - - if (losers.length === 2) { - const placementMatch = await this.createMatch( - [losers[0].id, losers[1].id], - event.currentRound, - MatchPhase.ELIMINATION, - ); - await this.startMatch(placementMatch.id); + await this.startMatch(finalMatch.id); + + // If this was the semi-final (2 matches in last round), also create third place match + if (lastMatches.length === 2) { + const loser1 = match1.teams.find((t) => t.id !== match1.winner?.id); + const loser2 = match2.teams.find((t) => t.id !== match2.winner?.id); + + if (loser1 && loser2) { + const thirdPlaceMatch = await this.createMatch( + [loser1.id, loser2.id], + event.currentRound, + MatchPhase.ELIMINATION, + ); + await this.startMatch(thirdPlaceMatch.id); + } } } @@ -1026,15 +1102,37 @@ export class MatchService { async getMatchById( matchId: string, relations: FindOptionsRelations = {}, + userId?: string, + adminReveal?: boolean, ): Promise { - return this.matchRepository.findOneOrFail({ + const match = await this.matchRepository.findOneOrFail({ where: { id: matchId }, relations, }); + + if (match.isRevealed) return match; + + if (userId) { + const eventId = match.teams?.[0]?.event?.id; + if ( + eventId && + (await this.eventService.isEventAdmin(eventId, userId)) && + adminReveal + ) { + return match; + } + } + + return { + ...match, + state: MatchState.PLANNED, + winner: null, + results: [], + }; } - revealMatch(matchId: string) { - return this.matchRepository.update(matchId, { + async revealMatch(matchId: string) { + await this.matchRepository.update(matchId, { isRevealed: true, }); } @@ -1059,6 +1157,114 @@ export class MatchService { } } + async cleanupMatchesInPhase(eventId: string, phase: MatchPhase) { + const matches = await this.matchRepository.find({ + where: { + teams: { + event: { + id: eventId, + }, + }, + phase: phase, + }, + }); + + if (matches.length > 0) { + await this.matchRepository.delete(matches.map((m) => m.id)); + } + + if (phase === MatchPhase.SWISS) { + await this.eventService.setCurrentRound(eventId, 0); + await this.teamService.resetSwissStatsForEvent(eventId); + } else if (phase === MatchPhase.ELIMINATION) { + await this.eventService.setCurrentRound(eventId, 0); + } + } + + async calculateRevealedBuchholzPointsForTeam( + teamId: string, + eventId: string, + ): Promise { + const results = await this.calculateBuchholzPointsForTeams( + [teamId], + eventId, + true, + ); + return results.get(teamId) || 0; + } + + async calculateBuchholzPointsForTeams( + teamIds: string[], + eventId: string, + revealedOnly: boolean, + ): Promise> { + const query = this.matchRepository + .createQueryBuilder("match") + .innerJoinAndSelect("match.teams", "team") + .leftJoinAndSelect("match.winner", "winner") + .where("team.event = :eventId", { eventId }) + .andWhere("match.phase = :phase", { phase: MatchPhase.SWISS }) + .andWhere("match.state = :state", { state: MatchState.FINISHED }); + + if (revealedOnly) { + query.andWhere("match.isRevealed = true"); + } + + const matches = await query.getMany(); + + // Fetch all teams for this event to get their bye status and current total scores + const teams = await this.dataSource.getRepository(TeamEntity).find({ + where: { event: { id: eventId } }, + select: ["id", "hadBye", "score"], + }); + + const teamsMap = new Map(teams.map((t) => [t.id, t])); + + // Calculate score for everyone (Revealed wins + Bye) + const scoresMap = new Map(); + if (revealedOnly) { + const winCounts = new Map(); + for (const m of matches) { + if (m.winner) { + winCounts.set(m.winner.id, (winCounts.get(m.winner.id) || 0) + 1); + } + } + for (const t of teams) { + const wins = winCounts.get(t.id) || 0; + const bye = t.hadBye ? 1 : 0; + scoresMap.set(t.id, wins + bye); + } + } else { + // For admins, we can use the DB score (which now includes byes) + for (const t of teams) { + scoresMap.set(t.id, t.score || 0); + } + } + + // Calculate Buchholz + const results = new Map(); + for (const teamId of teamIds) { + results.set(teamId, 0); + } + + const teamIdsSet = new Set(teamIds); + + for (const m of matches) { + const t1 = m.teams[0]?.id; + const t2 = m.teams[1]?.id; + if (t1 && t2) { + if (teamIdsSet.has(t1)) { + results.set(t1, (results.get(t1) || 0) + (scoresMap.get(t2) || 0)); + } + if (teamIdsSet.has(t2)) { + results.set(t2, (results.get(t2) || 0) + (scoresMap.get(t1) || 0)); + } + } + } + + return results; + } + getGlobalStats() { return this.matchStatsRepository .createQueryBuilder("match_stats") diff --git a/api/src/scripts/seed-users-teams.ts b/api/src/scripts/seed-users-teams.ts new file mode 100644 index 00000000..cdef214b --- /dev/null +++ b/api/src/scripts/seed-users-teams.ts @@ -0,0 +1,110 @@ +import "reflect-metadata"; +import { DataSource, DataSourceOptions } from "typeorm"; +import { EventEntity } from "../event/entities/event.entity"; +import { + UserEntity, + UserEventPermissionEntity, + PermissionRole, +} from "../user/entities/user.entity"; +import { TeamEntity } from "../team/entities/team.entity"; +import { config } from "dotenv"; +import { DatabaseConfig } from "../DatabaseConfig"; +import { ConfigService } from "@nestjs/config"; +import { join } from "path"; + +config(); + +async function bootstrap() { + const eventId = process.argv[2]; + if (!eventId) { + console.error("Please provide an eventId as the first argument"); + process.exit(1); + } + + console.log("Connecting to database..."); + const configService = new ConfigService(); + const databaseConfig = new DatabaseConfig(configService); + const baseConfig = databaseConfig.getConfig(true); + + // Use a glob pattern that works with ts-node in development + const dataSource = new DataSource({ + ...baseConfig, + entities: [join(__dirname, "..", "**", "*.entity.ts")], + } as DataSourceOptions); + + + await dataSource.initialize(); + console.log("Database connected!"); + + try { + const userRepository = dataSource.getRepository(UserEntity); + const teamRepository = dataSource.getRepository(TeamEntity); + const eventRepository = dataSource.getRepository(EventEntity); + const permissionRepository = dataSource.getRepository( + UserEventPermissionEntity, + ); + + const event = await eventRepository.findOne({ where: { id: eventId } }); + if (!event) { + throw new Error(`Event with ID ${eventId} not found`); + } + + console.log( + `Seeding 90 users and 30 teams for event: ${event.name} (${event.id})`, + ); + + const users: UserEntity[] = []; + const now = Date.now(); + for (let i = 1; i <= 90; i++) { + const user = new UserEntity(); + user.githubId = `seed-user-${i}-${now}`; + user.githubAccessToken = "dummy-token"; + user.email = `user${i}@example.com`; + user.username = `seeduser${i}_${now.toString().slice(-5)}`; + user.name = `Seed User ${i}`; + user.profilePicture = `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.username}`; + users.push(user); + } + + const savedUsers = await userRepository.save(users); + console.log(`Successfully saved 90 users`); + + // Add event permissions for these users + const permissions = savedUsers.map((user) => { + const perm = new UserEventPermissionEntity(); + perm.user = user; + perm.event = event; + perm.role = PermissionRole.USER; + return perm; + }); + await permissionRepository.save(permissions); + console.log(`Successfully added event permissions for 90 users`); + + const teams: TeamEntity[] = []; + for (let i = 1; i <= 30; i++) { + const team = new TeamEntity(); + team.name = `Seed Team ${i}`; + team.event = event; + + // Assign 3 users to each team + const teamUsers = savedUsers.slice((i - 1) * 3, i * 3); + team.users = teamUsers; + team.repo = "https://github.com/42core-team/monorepo"; + team.startedRepoCreationAt = new Date(); + + teams.push(team); + } + + await teamRepository.save(teams); + console.log(`Successfully saved 30 teams and assigned users`); + + console.log("Seeding completed successfully!"); + } finally { + await dataSource.destroy(); + } +} + +bootstrap().catch((err) => { + console.error("Error seeding data:", err); + process.exit(1); +}); diff --git a/api/src/team/team.controller.ts b/api/src/team/team.controller.ts index 98fac731..495def1b 100644 --- a/api/src/team/team.controller.ts +++ b/api/src/team/team.controller.ts @@ -42,9 +42,12 @@ export class TeamController { return this.teamService.getTeamById(id); } + @UseGuards(JwtAuthGuard) @Get(`event/:${EVENT_ID_PARAM}`) getTeamsForEvent( @EventId eventId: string, + @UserId() userId: string, + @Query("adminRevealQuery") adminRevealQuery: string, // It's a string in query params @Query("searchName") searchName?: string, @Query("sortDir") sortDir?: string, @Query("sortBy") sortBy?: string, @@ -54,6 +57,8 @@ export class TeamController { searchName, sortDir, sortBy, + userId, + adminRevealQuery === "true", ); } diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index ec7fc610..ccdd432a 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -21,6 +21,7 @@ import { FindOptionsRelations } from "typeorm/find-options/FindOptionsRelations" import { MatchService } from "../match/match.service"; import { Cron, CronExpression } from "@nestjs/schedule"; import { LockKeys } from "../constants"; +import { MatchEntity } from "../match/entites/match.entity"; @Injectable() export class TeamService { @@ -35,7 +36,7 @@ export class TeamService { private readonly matchService: MatchService, @InjectDataSource() private readonly dataSource: DataSource, - ) {} + ) { } logger = new Logger("TeamService"); @@ -370,6 +371,8 @@ export class TeamService { searchName?: string, searchDir?: string, sortBy?: string, + userId?: string, + adminReveal?: boolean, ): Promise< Array< TeamEntity & { @@ -377,23 +380,52 @@ export class TeamService { } > > { + const isAdmin = userId + ? await this.eventService.isEventAdmin(eventId, userId) + : false; + const revealAll = isAdmin && adminReveal; + const query = this.teamRepository .createQueryBuilder("team") .innerJoin("team.event", "event") .leftJoin("team.users", "user") .where("event.id = :eventId", { eventId }) - .andWhere("team.deletedAt IS NULL") - .select([ + .andWhere("team.deletedAt IS NULL"); + + if (revealAll) { + query.select([ "team.id", "team.name", "team.locked", - "team.repo", + "team.score", + "team.buchholzPoints", + "team.hadBye", "team.queueScore", "team.createdAt", "team.updatedAt", - ]) - .addSelect("COUNT(user.id)", "userCount") - .groupBy("team.id"); + ]); + } else { + // Calculate revealed score dynamically + query + .leftJoin( + MatchEntity, + "match", + "match.winnerId = team.id AND match.isRevealed = true AND match.phase = 'SWISS'", + ) + .select([ + "team.id", + "team.name", + "team.locked", + "team.hadBye", + "team.queueScore", + "team.createdAt", + "team.updatedAt", + "team.score", + ]) + .addSelect("COUNT(DISTINCT match.id)", "revealed_match_wins"); + } + + query.addSelect("COUNT(DISTINCT user.id)", "user_count").groupBy("team.id"); if (searchName) { query.andWhere("team.name LIKE :searchName", { @@ -403,27 +435,86 @@ export class TeamService { if (sortBy) { const direction = searchDir?.toUpperCase() === "DESC" ? "DESC" : "ASC"; + let sortColumn = sortBy; + const validSortColumns = [ "name", "locked", - "repo", + "score", "queueScore", "createdAt", "updatedAt", + "buchholzPoints", ]; - if (validSortColumns.includes(sortBy)) { - query.orderBy(`team.${sortBy}`, direction as "ASC" | "DESC"); + + if (validSortColumns.includes(sortColumn)) { + if (sortColumn === "score" && !revealAll) { + query.orderBy("revealed_match_wins", direction as "ASC" | "DESC"); + } else if (sortColumn === "buchholzPoints") { + if (revealAll) { + query.orderBy("team.buchholzPoints", direction as "ASC" | "DESC"); + } else { + throw new BadRequestException( + "Buchholz points are hidden for this event.", + ); + } + } else { + query.orderBy(`team.${sortColumn}`, direction as "ASC" | "DESC"); + } } + if (sortBy === "membersCount") { - query.orderBy("COUNT(user.id)", direction as "ASC" | "DESC"); + query.orderBy("COUNT(DISTINCT user.id)", direction as "ASC" | "DESC"); } } const result = await query.getRawAndEntities(); - return result.entities.map((team, idx) => ({ - ...team, - userCount: parseInt(result.raw[idx].userCount, 10), - })); + + // Batch calculate Buchholz points for all teams in the result + const teamIds = result.entities.map((t) => t.id); + const buchholzMap = await this.matchService.calculateBuchholzPointsForTeams( + teamIds, + eventId, + !revealAll, + ); + + // Map properties from raw if entity is missing them due to partial select + const teamsWithCounts = await Promise.all( + result.entities.map(async (team, idx) => { + const raw = result.raw[idx]; + + // Be robust about boolean hydration from different DB drivers/QueryBuilder setups + const hadByeRaw = raw.team_hadBye ?? raw.hadBye ?? raw.team_had_bye; + const hadBye = + team.hadBye || + hadByeRaw === "1" || + hadByeRaw === 1 || + hadByeRaw === true || + hadByeRaw === "true"; + + const mappedTeam: any = { + ...team, + hadBye, + userCount: parseInt(raw.user_count, 10) || 0, + }; + + if (revealAll) { + // For admins, use DB score which includes byes + mappedTeam.score = team.score || 0; + } else { + // For public, Revealed Match Wins + Bye + mappedTeam.score = + (parseInt(raw.revealed_match_wins, 10) || 0) + (hadBye ? 1 : 0); + } + + // Use the batch-calculated Buchholz points + mappedTeam.buchholzPoints = buchholzMap.get(team.id) || 0; + + return mappedTeam; + }), + ); + + return teamsWithCounts; } async joinQueue(teamId: string) { @@ -589,4 +680,13 @@ export class TeamService { return teams; } + + async resetSwissStatsForEvent(eventId: string) { + await this.teamRepository + .createQueryBuilder() + .update() + .set({ score: 0, buchholzPoints: 0, hadBye: false }) + .where("eventId = :eventId", { eventId }) + .execute(); + } } diff --git a/frontend/app/actions/team.ts b/frontend/app/actions/team.ts index aa98f4bc..588ff340 100644 --- a/frontend/app/actions/team.ts +++ b/frontend/app/actions/team.ts @@ -11,6 +11,8 @@ export interface Team { repo: string; inQueue: boolean; score: number; + buchholzPoints: number; + hadBye: boolean; queueScore: number; locked?: boolean; created?: string; @@ -67,16 +69,18 @@ export async function getTeamById(teamId: string): Promise { // TODO: directly return team object if API response is already in the correct format return team ? { - id: team.id, - name: team.name, - repo: team.repo || "", - locked: team.locked, - score: team.score, - queueScore: team.queueScore, - createdAt: team.createdAt, - inQueue: team.inQueue, - updatedAt: team.updatedAt, - } + id: team.id, + name: team.name, + repo: team.repo || "", + locked: team.locked, + score: team.score, + buchholzPoints: team.buchholzPoints || 0, + hadBye: team.hadBye || false, + queueScore: team.queueScore, + createdAt: team.createdAt, + inQueue: team.inQueue, + updatedAt: team.updatedAt, + } : null; } @@ -87,8 +91,7 @@ export async function hasEventStarted(teamId: string): Promise { export async function getMyEventTeam(eventId: string): Promise { const team = (await axiosInstance.get(`team/event/${eventId}/my`)).data; - if (!team) - return null; + if (!team) return null; // TODO: directly return team object if API response is already in the correct format return { @@ -97,6 +100,8 @@ export async function getMyEventTeam(eventId: string): Promise { repo: team.repo || "", locked: team.locked, score: team.score, + buchholzPoints: team.buchholzPoints || 0, + hadBye: team.hadBye || false, queueScore: team.queueScore, inQueue: team.inQueue, createdAt: team.createdAt, @@ -206,9 +211,12 @@ export async function getTeamsForEventTable( | "name" | "createdAt" | "membersCount" + | "score" + | "buchholzPoints" | "queueScore" | undefined = "name", sortDirection: "asc" | "desc" = "asc", + adminReveal: boolean = false, ) { const teams = ( await axiosInstance.get(`team/event/${eventId}/`, { @@ -216,6 +224,7 @@ export async function getTeamsForEventTable( searchName: searchTeamName, sortBy: sortColumn, sortDir: sortDirection, + adminRevealQuery: adminReveal, }, }) ).data; @@ -225,7 +234,10 @@ export async function getTeamsForEventTable( name: team.name, repo: team.repo || "", membersCount: team.userCount, - queueScore: team.queueScore || 0, + score: team.score ?? 0, + buchholzPoints: team.buchholzPoints ?? 0, + hadBye: team.hadBye ?? false, + queueScore: team.queueScore ?? 0, createdAt: team.createdAt, updatedAt: team.updatedAt, })); diff --git a/frontend/app/actions/tournament.ts b/frontend/app/actions/tournament.ts index d6d8327c..e707de76 100644 --- a/frontend/app/actions/tournament.ts +++ b/frontend/app/actions/tournament.ts @@ -60,6 +60,15 @@ export async function revealAllMatches( ); } +export async function cleanupAllMatches( + eventId: string, + phase: string, +): Promise> { + return handleError( + axiosInstance.put(`/match/cleanup-all/${eventId}/${phase}`), + ); +} + export async function getMatchById( matchId: string, ): Promise> { diff --git a/frontend/app/events/[id]/bracket/actions.tsx b/frontend/app/events/[id]/bracket/actions.tsx index 76b5d948..ce7834c0 100644 --- a/frontend/app/events/[id]/bracket/actions.tsx +++ b/frontend/app/events/[id]/bracket/actions.tsx @@ -1,5 +1,30 @@ "use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; export default function Actions() { - return <>; + const router = useRouter(); + const searchParams = useSearchParams(); + const isAdminView = searchParams.get("adminReveal") === "true"; + + return ( +
+ { + const params = new URLSearchParams(searchParams.toString()); + params.set("adminReveal", value ? "true" : "false"); + router.replace(`?${params.toString()}`); + }} + /> + +
+ ); } diff --git a/frontend/app/events/[id]/bracket/graphView.tsx b/frontend/app/events/[id]/bracket/graphView.tsx index 2b2f9a19..1b294a2f 100644 --- a/frontend/app/events/[id]/bracket/graphView.tsx +++ b/frontend/app/events/[id]/bracket/graphView.tsx @@ -1,12 +1,11 @@ "use client"; -import type { Node } from "reactflow"; +import type { Node, Edge } from "reactflow"; import type { Match } from "@/app/actions/tournament-model"; import { useParams, useRouter } from "next/navigation"; import { useEffect } from "react"; import ReactFlow, { Background, useEdgesState, useNodesState } from "reactflow"; import { MatchState } from "@/app/actions/tournament-model"; import { MatchNode } from "@/components/match"; -import { Switch } from "@/components/ui/switch"; import "reactflow/dist/style.css"; const MATCH_WIDTH = 200; @@ -18,25 +17,6 @@ const nodeTypes = { matchNode: MatchNode, }; -function createTreeCoordinate(matchCount: number): { x: number; y: number }[] { - const coordinates: { x: number; y: number }[] = []; - const totalRounds = Math.ceil(Math.log2(matchCount + 1)); - - for (let round = 0; round < totalRounds; round++) { - const matchesInRound = 2 ** (totalRounds - round - 1); - const spacing = 2 ** round * VERTICAL_SPACING; - - for (let match = 0; match < matchesInRound; match++) { - const x = round * ROUND_SPACING; - const y = match * spacing + spacing / 2; - - coordinates.push({ x, y }); - } - } - - return coordinates; -} - function getTotalRounds(teamCount: number) { if (teamCount <= 1) return 1; return Math.ceil(Math.log2(teamCount)); @@ -54,20 +34,34 @@ export default function GraphView({ isAdminView: boolean; }) { const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, _setEdges, onEdgesChange] = useEdgesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); const router = useRouter(); const eventId = useParams().id as string; useEffect(() => { + const newNodes: Node[] = []; + const newEdges: Edge[] = []; + const nodeIdsByRound: Map = new Map(); + if (!matches || matches.length === 0) { // Create placeholder nodes for visualization - const newNodes = createTreeCoordinate(teamCount / 2).map( - (coord, index): Node => { + const totalRounds = getTotalRounds(teamCount); + + for (let round = 0; round < totalRounds; round++) { + const matchesInRound = 2 ** (totalRounds - round - 1); + const spacing = 2 ** round * VERTICAL_SPACING; + const roundNodeIds: string[] = []; + + for (let match = 0; match < matchesInRound; match++) { + const id = `placeholder-${round}-${match}`; + const x = round * ROUND_SPACING; + const y = match * spacing + spacing / 2; + const placeholderMatch: Match = { id: ``, isRevealed: false, - round: index + 1, + round: round, state: "PLANNED" as any, phase: "ELIMINATION" as any, createdAt: new Date().toISOString(), @@ -76,152 +70,185 @@ export default function GraphView({ results: [], }; - return { - id: index.toString(), + newNodes.push({ + id, type: "matchNode", - position: { x: coord.x, y: coord.y }, + position: { x, y }, data: { match: placeholderMatch, width: MATCH_WIDTH, height: MATCH_HEIGHT, + showTargetHandle: round > 0, + showSourceHandle: round < totalRounds - 1, }, - }; - }, + }); + roundNodeIds.push(id); + } + nodeIdsByRound.set(round, roundNodeIds); + } + } else { + // Create nodes from actual match data + const sortedMatches = [...matches].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); - setNodes(newNodes); - return; - } - - // Create nodes from actual match data - const sortedMatches = [...matches].sort( - (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); - const matchesByRound = new Map(); - for (const match of sortedMatches) { - if (!matchesByRound.has(match.round)) { - matchesByRound.set(match.round, []); + const matchesByRound = new Map(); + for (const match of sortedMatches) { + if (!matchesByRound.has(match.round)) { + matchesByRound.set(match.round, []); + } + matchesByRound.get(match.round)!.push(match); } - matchesByRound.get(match.round)!.push(match); - } - const totalRounds = getTotalRounds(teamCount); - const lastRound = totalRounds - 1; - const newNodes: Node[] = []; + const totalRounds = getTotalRounds(teamCount); + const lastRoundIndex = totalRounds - 1; + const roundKeys = Array.from(matchesByRound.keys()).sort((a, b) => a - b); - const roundKeys = Array.from(matchesByRound.keys()).sort((a, b) => a - b); - for (const round of roundKeys) { - const roundMatches = matchesByRound.get(round) || []; - const placementMatch = - round === lastRound - ? roundMatches.find((match) => match.isPlacementMatch) - : undefined; - const bracketMatches = - round === lastRound + for (const round of roundKeys) { + const roundIndex = round; + const roundMatches = matchesByRound.get(round) || []; + const isLastRound = roundIndex === lastRoundIndex; + + const bracketMatches = isLastRound ? roundMatches.filter((match) => !match.isPlacementMatch) : roundMatches; - const spacing = 2 ** round * VERTICAL_SPACING; - - bracketMatches.forEach((match, index) => { - const coord = { - x: round * ROUND_SPACING, - y: index * spacing + spacing / 2, - }; - - newNodes.push({ - id: match.id ?? `match-${round}-${index}`, - type: "matchNode", - position: { x: coord.x, y: coord.y }, - data: { - match, - width: MATCH_WIDTH, - height: MATCH_HEIGHT, - onClick: (clickedMatch: Match) => { - if ( - (match.state === MatchState.FINISHED || isEventAdmin) && - clickedMatch.id - ) - router.push(`/events/${eventId}/match/${clickedMatch.id}`); + + // Ensure bracket matches are sorted consistently for edge creation + bracketMatches.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + const spacing = 2 ** roundIndex * VERTICAL_SPACING; + const roundNodeIds: string[] = []; + + bracketMatches.forEach((match, index) => { + const id = match.id ?? `match-${roundIndex}-${index}`; + const coord = { + x: roundIndex * ROUND_SPACING, + y: index * spacing + spacing / 2, + }; + + newNodes.push({ + id, + type: "matchNode", + position: { x: coord.x, y: coord.y }, + data: { + match, + width: MATCH_WIDTH, + height: MATCH_HEIGHT, + showTargetHandle: roundIndex > 0, + showSourceHandle: roundIndex < lastRoundIndex, + onClick: (clickedMatch: Match) => { + if ( + (match.state === MatchState.FINISHED || isEventAdmin) && + clickedMatch.id + ) + router.push(`/events/${eventId}/match/${clickedMatch.id}`); + }, }, - }, + }); + roundNodeIds.push(id); }); - }); + nodeIdsByRound.set(roundIndex, roundNodeIds); - if (placementMatch) { - const placementCoord = { - x: round * ROUND_SPACING, - y: spacing / 2 + VERTICAL_SPACING * 2, - }; - - newNodes.push({ - id: placementMatch.id ?? `placement-${round}`, - type: "matchNode", - position: { x: placementCoord.x, y: placementCoord.y }, - data: { - match: placementMatch, - width: MATCH_WIDTH, - height: MATCH_HEIGHT, - onClick: (clickedMatch: Match) => { - if ( - (placementMatch.state === MatchState.FINISHED || - isEventAdmin) && - clickedMatch.id - ) - router.push(`/events/${eventId}/match/${clickedMatch.id}`); + const placementMatch = isLastRound + ? roundMatches.find((match) => match.isPlacementMatch) + : undefined; + if (placementMatch) { + const placementId = placementMatch.id ?? `placement-${roundIndex}`; + const placementCoord = { + x: roundIndex * ROUND_SPACING, + y: spacing / 2 + VERTICAL_SPACING * 1.5, + }; + + newNodes.push({ + id: placementId, + type: "matchNode", + position: { x: placementCoord.x, y: placementCoord.y }, + data: { + match: placementMatch, + width: MATCH_WIDTH, + height: MATCH_HEIGHT, + showTargetHandle: false, + showSourceHandle: false, + onClick: (clickedMatch: Match) => { + if ( + (placementMatch.state === MatchState.FINISHED || + isEventAdmin) && + clickedMatch.id + ) + router.push(`/events/${eventId}/match/${clickedMatch.id}`); + }, }, - }, - }); + }); + } } } + // Generate edges between rounds + const rounds = Array.from(nodeIdsByRound.keys()).sort((a, b) => a - b); + for (let i = 0; i < rounds.length - 1; i++) { + const currentRound = rounds[i]; + const nextRound = rounds[i + 1]; + const currentNodes = nodeIdsByRound.get(currentRound) || []; + const nextNodes = nodeIdsByRound.get(nextRound) || []; + + currentNodes.forEach((nodeId, index) => { + const targetIndex = Math.floor(index / 2); + if (nextNodes[targetIndex]) { + newEdges.push({ + id: `edge-${currentRound}-${index}`, + source: nodeId, + target: nextNodes[targetIndex], + type: "smoothstep", + animated: false, + style: { + stroke: "#64748b", // Muted slate color + strokeWidth: 2, + opacity: 0.5, + }, + }); + } + }); + } + setNodes(newNodes); - }, [matches, teamCount, isEventAdmin, router, eventId, setNodes]); + setEdges(newEdges); + }, [matches, teamCount, isEventAdmin, router, eventId, setNodes, setEdges]); return ( -
-
- - {isEventAdmin && ( -
- Toggle admin view - { - const params = new URLSearchParams(window.location.search); - params.set("adminReveal", value ? "true" : "false"); - router.replace(`?${params.toString()}`); - }} - defaultChecked={isAdminView} - /> -
- )} - - - -
+
+ + +
); } diff --git a/frontend/app/events/[id]/bracket/page.tsx b/frontend/app/events/[id]/bracket/page.tsx index 19f03831..caf7a029 100644 --- a/frontend/app/events/[id]/bracket/page.tsx +++ b/frontend/app/events/[id]/bracket/page.tsx @@ -33,18 +33,32 @@ export default async function page({ const teamCount = await getTournamentTeamCount(eventId); return ( -
-
- +
+
+
+

+ Tournament Tree +

+

+ Follow the elimination bracket to see which teams advance and + ultimately compete in the finals. +

+
+ {eventAdmin && ( +
+ +
+ )} +
+ +
+
-

Tournament Tree

-

-
); } diff --git a/frontend/app/events/[id]/dashboard/dashboard.tsx b/frontend/app/events/[id]/dashboard/dashboard.tsx index 63359a23..b8c7071c 100644 --- a/frontend/app/events/[id]/dashboard/dashboard.tsx +++ b/frontend/app/events/[id]/dashboard/dashboard.tsx @@ -19,6 +19,7 @@ import { import { lockEvent, unlockEvent } from "@/app/actions/team"; import { + cleanupAllMatches, revealAllMatches, startSwissMatches, startTournamentMatches, @@ -218,6 +219,23 @@ export function DashboardPage({ eventId }: DashboardPageProps) { }, }); + const cleanupMatchesMutation = useMutation({ + mutationFn: async (phase: string) => { + const result = await cleanupAllMatches(eventId, phase); + if (isActionError(result)) { + throw new Error(result.error); + } + return result; + }, + onSuccess: async () => { + toast.success("Matches cleaned up."); + await queryClient.invalidateQueries({ queryKey: ["event", eventId] }); + }, + onError: (e: any) => { + toast.error(e.message || "Failed to cleanup matches."); + }, + }); + const setTeamsLockDateMutation = useMutation({ mutationFn: async (lockDate: number | null) => { const result = await setEventTeamsLockDate(eventId, lockDate); @@ -631,6 +649,26 @@ export function DashboardPage({ eventId }: DashboardPageProps) { > Reveal Group Phase Matches +
@@ -653,6 +691,26 @@ export function DashboardPage({ eventId }: DashboardPageProps) { > Reveal Tournament Matches +
diff --git a/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx b/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx new file mode 100644 index 00000000..847d4862 --- /dev/null +++ b/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { BarChart3, Network } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import GraphView from "./graphView"; +import RankingTable from "./RankingTable"; +import type { Team } from "@/app/actions/team"; +import type { Match } from "@/app/actions/tournament-model"; + +interface GroupPhaseTabsProps { + eventId: string; + matches: Match[]; + teams: Team[]; + eventAdmin: boolean; + isAdminView: boolean; + advancementCount: number; +} + +export default function GroupPhaseTabs({ + eventId, + matches, + teams, + eventAdmin, + isAdminView, + advancementCount, +}: GroupPhaseTabsProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const currentTab = searchParams.get("tab") || "graph"; + + const onTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", value); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }; + + return ( + +
+ + + + Graph + + + + Ranking + + +
+ + +
+ +
+
+ + +
+ +
+
+
+ ); +} diff --git a/frontend/app/events/[id]/groups/RankingTable.tsx b/frontend/app/events/[id]/groups/RankingTable.tsx new file mode 100644 index 00000000..bdefbe62 --- /dev/null +++ b/frontend/app/events/[id]/groups/RankingTable.tsx @@ -0,0 +1,146 @@ +"use client"; + +import type { Team } from "@/app/actions/team"; +import type { Match } from "@/app/actions/tournament-model"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import { Fragment } from "react"; + +interface RankingTableProps { + teams: Team[]; + matches: Match[]; + eventId: string; + advancementCount: number; +} + +export default function RankingTable({ + teams, + matches, + eventId, + advancementCount, +}: RankingTableProps) { + // Sort teams by score (desc), then buchholzPoints (desc) + const sortedTeams = [...teams].sort((a, b) => { + const scoreA = a.score ?? 0; + const scoreB = b.score ?? 0; + const buchholzA = a.buchholzPoints ?? 0; + const buchholzB = b.buchholzPoints ?? 0; + + if (scoreB !== scoreA) return scoreB - scoreA; + return buchholzB - buchholzA; + }); + + const getMatchHistory = (teamId: string) => { + return matches + .filter( + (m) => m.state === "FINISHED" && m.teams.some((t) => t.id === teamId), + ) + .sort((a, b) => a.round - b.round) + .map((m) => { + if (!m.winner) return "T"; // Tie (not really possible in currently implemented swiss but good for safety) + return m.winner.id === teamId ? "W" : "L"; + }); + }; + + return ( +
+ + + + Rank + Participant + Score + Buchholz + Byes + Match History + + + + {sortedTeams.map((team, index) => { + const rank = index + 1; + const history = getMatchHistory(team.id); + const isAtCutoff = rank === advancementCount; + + return ( + + + + {rank} + + +
+ + {team.name} + +
+
+ + {(team.score ?? 0).toFixed(1)} + + + {(team.buchholzPoints ?? 0).toFixed(1)} + + + + {team.hadBye ? "+1.0" : "0"} + + + +
+ {history.map((result, i) => ( +
+ {result} +
+ ))} + {history.length === 0 && ( + + No matches + + )} +
+
+
+ {isAtCutoff && index < sortedTeams.length - 1 && ( + + +
+
+ + Advancement Cutoff + +
+
+ + + )} + + ); + })} + +
+
+ ); +} diff --git a/frontend/app/events/[id]/groups/actions.tsx b/frontend/app/events/[id]/groups/actions.tsx index 76b5d948..d9ed64ff 100644 --- a/frontend/app/events/[id]/groups/actions.tsx +++ b/frontend/app/events/[id]/groups/actions.tsx @@ -1,5 +1,30 @@ "use client"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; export default function Actions() { - return <>; + const router = useRouter(); + const searchParams = useSearchParams(); + const isAdminView = searchParams.get("adminReveal") === "true"; + + return ( +
+ { + const params = new URLSearchParams(searchParams.toString()); + params.set("adminReveal", value ? "true" : "false"); + router.replace(`?${params.toString()}`); + }} + /> + +
+ ); } diff --git a/frontend/app/events/[id]/groups/graphView.tsx b/frontend/app/events/[id]/groups/graphView.tsx index e30b04f0..ed886131 100644 --- a/frontend/app/events/[id]/groups/graphView.tsx +++ b/frontend/app/events/[id]/groups/graphView.tsx @@ -10,8 +10,15 @@ import { Switch } from "@/components/ui/switch"; import "reactflow/dist/style.css"; // Custom node types for ReactFlow +const RoundNode = ({ data }: { data: { label: string } }) => ( +
+ {data.label} +
+); + const nodeTypes = { matchNode: MatchNode, + roundNode: RoundNode, }; export default function GraphView({ @@ -46,11 +53,11 @@ export default function GraphView({ const newNodes: Node[] = []; - const COLUMN_WIDTH = 300; + const COLUMN_WIDTH = 320; const ROW_HEIGHT = 130; const PADDING = 20; - const MATCH_WIDTH = 250; - const MATCH_HEIGHT = 80; + const MATCH_WIDTH = 280; + const MATCH_HEIGHT = 100; rounds.forEach((round, roundIndex) => { const roundMatches = matchesByRound[round]; @@ -58,6 +65,7 @@ export default function GraphView({ // Add round header newNodes.push({ id: `round-${round}`, + type: "roundNode", position: { x: roundIndex * COLUMN_WIDTH + PADDING, y: PADDING, @@ -67,13 +75,7 @@ export default function GraphView({ }, style: { width: COLUMN_WIDTH - PADDING * 2, - height: 40, - textAlign: "center", - fontWeight: "bold", - padding: "10px", - backgroundColor: "#f1f5f9", - border: "2px solid #cbd5e1", - borderRadius: "8px", + height: 60, }, draggable: false, selectable: false, @@ -85,7 +87,7 @@ export default function GraphView({ roundIndex * COLUMN_WIDTH + PADDING + (COLUMN_WIDTH - MATCH_WIDTH - PADDING * 2) / 2; - const yPos = (matchIndex + 1) * ROW_HEIGHT + PADDING + 20; // +60 for header space + const yPos = (matchIndex + 1) * ROW_HEIGHT + PADDING + 20; newNodes.push({ id: match.id ?? `match-${round}-${matchIndex}`, @@ -108,36 +110,37 @@ export default function GraphView({ }); setNodes(newNodes); - }, [matches]); + }, [matches, eventAdmin, eventId, router]); return ( -
- {eventAdmin && ( -
- Toggle admin view - { - const params = new URLSearchParams(window.location.search); - params.set("adminReveal", value ? "true" : "false"); - router.replace(`?${params.toString()}`); - }} - defaultChecked={isAdminView} - /> -
- )} +
- +
); diff --git a/frontend/app/events/[id]/groups/page.tsx b/frontend/app/events/[id]/groups/page.tsx index 187b2901..6c79a1e0 100644 --- a/frontend/app/events/[id]/groups/page.tsx +++ b/frontend/app/events/[id]/groups/page.tsx @@ -1,8 +1,12 @@ import { isActionError } from "@/app/actions/errors"; import { isEventAdmin } from "@/app/actions/event"; -import { getSwissMatches } from "@/app/actions/tournament"; +import { getTeamsForEventTable } from "@/app/actions/team"; +import { + getSwissMatches, + getTournamentTeamCount, +} from "@/app/actions/tournament"; import Actions from "@/app/events/[id]/groups/actions"; -import GraphView from "@/app/events/[id]/groups/graphView"; +import GroupPhaseTabs from "@/app/events/[id]/groups/GroupPhaseTabs"; export const metadata = { title: "Group Phase", @@ -15,30 +19,47 @@ export default async function page({ searchParams, }: { params: Promise<{ id: string }>; - searchParams: Promise<{ adminReveal?: string }>; + searchParams: Promise<{ adminReveal?: string; tab?: string }>; }) { const eventId = (await params).id; const isAdminView = (await searchParams).adminReveal === "true"; - const matches = await getSwissMatches(eventId, isAdminView); - const eventAdmin = await isEventAdmin(eventId); + const [matches, eventAdmin, teams, advancementCount] = await Promise.all([ + getSwissMatches(eventId, isAdminView), + isEventAdmin(eventId), + getTeamsForEventTable(eventId, undefined, "score", "desc", isAdminView), + getTournamentTeamCount(eventId), + ]); + if (isActionError(eventAdmin)) { throw new Error("Failed to verify admin status"); } return ( -
-
- +
+
+
+

+ Group Phase +

+

+ In the group phase, teams compete using the Swiss tournament system, + with rankings determined by the Buchholz scoring system. +

+
+ {eventAdmin && ( +
+ +
+ )}
-

Group phase

-

- In the group phase, teams compete using the Swiss tournament system, - with rankings determined by the Buchholz scoring system. -

-
); diff --git a/frontend/components/match/MatchNode.tsx b/frontend/components/match/MatchNode.tsx index 3ea5558b..66703ea5 100644 --- a/frontend/components/match/MatchNode.tsx +++ b/frontend/components/match/MatchNode.tsx @@ -4,12 +4,16 @@ import type { Match } from "@/app/actions/tournament-model"; import { motion } from "framer-motion"; import { memo } from "react"; import { MatchState } from "@/app/actions/tournament-model"; +import { useParams, useRouter } from "next/navigation"; +import { Handle, Position } from "reactflow"; interface MatchNodeData { match: Match; width?: number; height?: number; onClick?: (match: Match) => void; + showTargetHandle?: boolean; + showSourceHandle?: boolean; } interface MatchNodeProps { @@ -59,9 +63,20 @@ function getMatchStateIcon(state: MatchState) { } function MatchNode({ data }: MatchNodeProps) { - const { match, width = 200, height = 80, onClick } = data; + const { + match, + width = 200, + height = 80, + onClick, + showTargetHandle = false, + showSourceHandle = false, + } = data; const styles = getMatchStateStyles(match.state); const icon = getMatchStateIcon(match.state); + const router = useRouter(); + const params = useParams<{ id?: string }>(); + const rawId = params?.id; + const eventId = rawId ?? ""; const handleClick = () => { onClick?.(match); @@ -87,6 +102,23 @@ function MatchNode({ data }: MatchNodeProps) { whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} > + {showTargetHandle && ( + + )} + {showSourceHandle && ( + + )} + {/* Animated progress indicator for IN_PROGRESS matches */} {match.state === MatchState.IN_PROGRESS && ( {match.teams && - match.state === MatchState.FINISHED && - match.teams.length > 0 ? ( + match.state === MatchState.FINISHED && + match.teams.length > 0 ? ( match.teams.map((team, index) => (
- - {formatTeamName(team.name)} +
+ { + e.stopPropagation(); + if (team.id && eventId) { + router.push(`/events/${eventId}/teams/${team.id}`); + } + }} + > + {formatTeamName(team.name)} + {team.id === match.winner?.id && ( 👑 )} - +
{match.state === MatchState.FINISHED && team.score !== undefined && ( diff --git a/frontend/components/team/TeamCreationSection.tsx b/frontend/components/team/TeamCreationSection.tsx index 2e62b99f..2cd5ff21 100644 --- a/frontend/components/team/TeamCreationSection.tsx +++ b/frontend/components/team/TeamCreationSection.tsx @@ -22,24 +22,34 @@ export function TeamCreationSection({ return ( - Create Your Team + + Create Your Team + -
+
{ + e.preventDefault(); + if (newTeamName && !validationError && !isLoading) { + handleCreateTeam(); + } + }} + className="flex flex-row gap-2" + > setNewTeamName(e.target.value)} + onChange={(e) => setNewTeamName(e.target.value)} /> -
+
{validationError && (
{validationError}