From 3506e4049a25a916edf3a7c308f84bb44ee3f9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Gro=C3=9Fmann?= Date: Wed, 4 Feb 2026 23:09:51 +0100 Subject: [PATCH 01/10] feat: Introduce group phase page with tabbed ranking and graph views, backed by new team scoring metrics. --- api/src/team/team.service.ts | 45 +++--- frontend/app/actions/team.ts | 34 +++-- .../app/events/[id]/groups/GroupPhaseTabs.tsx | 77 ++++++++++ .../app/events/[id]/groups/RankingTable.tsx | 141 ++++++++++++++++++ frontend/app/events/[id]/groups/page.tsx | 36 +++-- .../events/[id]/teams/[teamId]/BackButton.tsx | 2 +- 6 files changed, 288 insertions(+), 47 deletions(-) create mode 100644 frontend/app/events/[id]/groups/GroupPhaseTabs.tsx create mode 100644 frontend/app/events/[id]/groups/RankingTable.tsx diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index 4e3b7366..4596159a 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -29,7 +29,7 @@ export class TeamService { private readonly matchService: MatchService, @InjectDataSource() private readonly dataSource: DataSource, - ) { } + ) {} logger = new Logger("TeamService"); @@ -352,6 +352,9 @@ export class TeamService { "team.name", "team.locked", "team.repo", + "team.score", + "team.buchholzPoints", + "team.hadBye", "team.queueScore", "team.createdAt", "team.updatedAt", @@ -371,6 +374,8 @@ export class TeamService { "name", "locked", "repo", + "score", + "buchholzPoints", "queueScore", "createdAt", "updatedAt", @@ -491,22 +496,22 @@ export class TeamService { }); } - async isTeamFull(teamId: string) { - const team = await this.teamRepository.findOne({ - where: { - id: teamId, - }, - relations: { - event: true, - users: true - } - }); - const maxUsers = team?.event.maxTeamSize; - if (!maxUsers) return true; - if (!team?.users) return true; - - return team?.users.length >= maxUsers; - } + async isTeamFull(teamId: string) { + const team = await this.teamRepository.findOne({ + where: { + id: teamId, + }, + relations: { + event: true, + users: true, + }, + }); + const maxUsers = team?.event.maxTeamSize; + if (!maxUsers) return true; + if (!team?.users) return true; + + return team?.users.length >= maxUsers; + } async leaveQueue(teamId: string) { return this.teamRepository.update(teamId, { inQueue: false }); @@ -536,12 +541,10 @@ export class TeamService { }); for (const team of teams) { - if (!team.repo || !team.event) - continue; + if (!team.repo || !team.event) continue; for (const user of team.users) { - if (!user.username) - continue; + if (!user.username) continue; await this.githubApiService.addWritePermissions( user.username, diff --git a/frontend/app/actions/team.ts b/frontend/app/actions/team.ts index aa98f4bc..1e785c7a 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,6 +211,8 @@ export async function getTeamsForEventTable( | "name" | "createdAt" | "membersCount" + | "score" + | "buchholzPoints" | "queueScore" | undefined = "name", sortDirection: "asc" | "desc" = "asc", @@ -225,6 +232,9 @@ export async function getTeamsForEventTable( name: team.name, repo: team.repo || "", membersCount: team.userCount, + 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/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..42535b2f --- /dev/null +++ b/frontend/app/events/[id]/groups/RankingTable.tsx @@ -0,0 +1,141 @@ +"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) => { + if (b.score !== a.score) return b.score - a.score; + return b.buchholzPoints - a.buchholzPoints; + }); + + 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.toFixed(1)} + + + {team.buchholzPoints.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/page.tsx b/frontend/app/events/[id]/groups/page.tsx index ee4e15e3..eae95c7d 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,18 +19,23 @@ 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"), + getTournamentTeamCount(eventId), + ]); + if (isActionError(eventAdmin)) { throw new Error("Failed to verify admin status"); } return ( -
+

@@ -44,13 +53,14 @@ export default async function page({ )}

-
- -
+
); } diff --git a/frontend/app/events/[id]/teams/[teamId]/BackButton.tsx b/frontend/app/events/[id]/teams/[teamId]/BackButton.tsx index 8eadc99a..c4747c3c 100644 --- a/frontend/app/events/[id]/teams/[teamId]/BackButton.tsx +++ b/frontend/app/events/[id]/teams/[teamId]/BackButton.tsx @@ -14,7 +14,7 @@ export default function BackButton() { variant="ghost" aria-label="Back to teams list" onClick={() => { - router.push(`/events/${eventId}/teams`); + router.back(); }} > From 25e9dd5b73c59df6253f9a259211ba8bb559a713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Gro=C3=9Fmann?= Date: Wed, 4 Feb 2026 23:41:54 +0100 Subject: [PATCH 02/10] feat: fixed the exposure of match data if match did not get revelead yet --- api/src/match/entites/match.entity.ts | 2 +- api/src/match/match.controller.ts | 13 +++- api/src/match/match.service.ts | 63 +++++++++++++++- api/src/team/entities/team.entity.ts | 2 +- api/src/team/team.controller.ts | 32 +++++--- api/src/team/team.service.ts | 103 ++++++++++++++++++++++---- 6 files changed, 182 insertions(+), 33 deletions(-) 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..6925e652 100644 --- a/api/src/match/match.controller.ts +++ b/api/src/match/match.controller.ts @@ -172,8 +172,19 @@ export class MatchController { @Get(":matchId") async getMatchById( @Param("matchId", ParseUUIDPipe) matchId: string, + @UserId() userId: string, + @Query("adminRevealQuery") adminRevealQuery: boolean, ): Promise { - return await this.matchService.getMatchById(matchId); + return await this.matchService.getMatchById( + matchId, + { + teams: { + event: true, + }, + }, + userId, + Boolean(adminRevealQuery), + ); } @UseGuards(JwtAuthGuard) diff --git a/api/src/match/match.service.ts b/api/src/match/match.service.ts index 9a3ceab2..9c24fa7a 100644 --- a/api/src/match/match.service.ts +++ b/api/src/match/match.service.ts @@ -1021,15 +1021,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, }); } @@ -1054,6 +1076,41 @@ export class MatchService { } } + async calculateRevealedBuchholzPointsForTeam( + teamId: string, + eventId: string, + ): Promise { + // A team's revealed Buchholz points is the sum of revealed scores of its opponents. + // We already have getFormerOpponents, but that's for ALL finished matches. + // For "revealed" Buchholz, we only care about Swiss phase. + + const matches = await this.matchRepository.find({ + where: { + teams: { id: teamId }, + phase: MatchPhase.SWISS, + state: MatchState.FINISHED, + }, + relations: { teams: true }, + }); + + let totalRevealedBuchholz = 0; + for (const match of matches) { + const opponent = match.teams.find((t) => t.id !== teamId); + if (opponent) { + // Opponent's revealed score = count of revealed wins in Swiss + const revealedWins = await this.matchRepository.count({ + where: { + winner: { id: opponent.id }, + isRevealed: true, + phase: MatchPhase.SWISS, + }, + }); + totalRevealedBuchholz += revealedWins; + } + } + return totalRevealedBuchholz; + } + getGlobalStats() { return this.matchStatsRepository .createQueryBuilder("match_stats") diff --git a/api/src/team/entities/team.entity.ts b/api/src/team/entities/team.entity.ts index ddfc8a5c..ace7d327 100644 --- a/api/src/team/entities/team.entity.ts +++ b/api/src/team/entities/team.entity.ts @@ -27,7 +27,7 @@ export class TeamEntity { @Column({ nullable: true }) repo: string; - @Column({nullable: true, type: "timestamp" }) + @Column({ nullable: true, type: "timestamp" }) startedRepoCreationAt: Date | null; @Column({ default: 0 }) diff --git a/api/src/team/team.controller.ts b/api/src/team/team.controller.ts index ff7ef50f..fb8b4c3f 100644 --- a/api/src/team/team.controller.ts +++ b/api/src/team/team.controller.ts @@ -35,16 +35,19 @@ export class TeamController { private readonly teamService: TeamService, private readonly userService: UserService, private readonly eventService: EventService, - ) { } + ) {} @Get(":id") getTeamById(@Param("id", new ParseUUIDPipe()) id: string) { 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", ); } @@ -134,15 +139,13 @@ export class TeamController { @EventId eventId: string, @Body() inviteUserDto: InviteUserDto, @Team() team: TeamEntity, - @UserId() userId: string + @UserId() userId: string, ) { - if(userId === inviteUserDto.userToInviteId) - throw new BadRequestException( - "You cannot invite yourself to a team.", - ) + if (userId === inviteUserDto.userToInviteId) + throw new BadRequestException("You cannot invite yourself to a team."); - if(await this.teamService.isTeamFull(team.id)) - throw new BadRequestException("This team is full."); + if (await this.teamService.isTeamFull(team.id)) + throw new BadRequestException("This team is full."); if ( await this.teamService.getTeamOfUserForEvent( eventId, @@ -175,9 +178,14 @@ export class TeamController { @EventId eventId: string, @Param("searchQuery") searchQuery: string, @Team() team: TeamEntity, - @UserId() userId: string + @UserId() userId: string, ) { - return this.userService.searchUsersForInvite(eventId, searchQuery, team.id, userId); + return this.userService.searchUsersForInvite( + eventId, + searchQuery, + team.id, + userId, + ); } @UseGuards(JwtAuthGuard) @@ -203,8 +211,8 @@ export class TeamController { if (!(await this.teamService.isUserInvitedToTeam(userId, teamId))) throw new BadRequestException("You are not invited to this team."); - if(await this.teamService.isTeamFull(teamId)) - throw new BadRequestException("This team is full."); + if (await this.teamService.isTeamFull(teamId)) + throw new BadRequestException("This team is full."); return this.teamService.acceptTeamInvite(userId, teamId); } diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index 4596159a..38603316 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -15,6 +15,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 { @@ -334,6 +335,8 @@ export class TeamService { searchName?: string, searchDir?: string, sortBy?: string, + userId?: string, + adminReveal?: boolean, ): Promise< Array< TeamEntity & { @@ -341,26 +344,55 @@ 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", + ]) + .addSelect("COUNT(DISTINCT match.id)", "revealed_score"); + + // Buchholz points are harder to calculate in a single query without nested aggregation. + // We'll calculate them in JS for now or accept that for search it might be 0/placeholder if too complex. + // However, for the Ranking Table, we need them. + } + + query.addSelect("COUNT(DISTINCT user.id)", "user_count").groupBy("team.id"); if (searchName) { query.andWhere("team.name LIKE :searchName", { @@ -370,29 +402,70 @@ export class TeamService { if (sortBy) { const direction = searchDir?.toUpperCase() === "DESC" ? "DESC" : "ASC"; + let sortColumn = sortBy; + const validSortColumns = [ "name", "locked", - "repo", "score", - "buchholzPoints", "queueScore", "createdAt", "updatedAt", ]; - if (validSortColumns.includes(sortBy)) { - query.orderBy(`team.${sortBy}`, direction as "ASC" | "DESC"); + + if (validSortColumns.includes(sortColumn)) { + if (!revealAll && sortColumn === "score") { + query.orderBy("revealed_score", direction as "ASC" | "DESC"); + } else { + query.orderBy(`team.${sortColumn}`, direction as "ASC" | "DESC"); + } + } + + if (sortBy === "buchholzPoints") { + if (revealAll) { + query.orderBy("team.buchholzPoints", direction as "ASC" | "DESC"); + } else { + // If sorting by Buchholz and not revealed, we might just skip or sort by score + query.orderBy("revealed_score", 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), - })); + + const teamsWithCounts = result.entities.map((team, idx) => { + const raw = result.raw[idx]; + const mappedTeam = { + ...team, + userCount: parseInt(raw.user_count, 10), + }; + + if (!revealAll) { + (mappedTeam as any).score = parseInt(raw.revealed_score, 10); + // Buchholz points will be calculated below for consistent Ranking Table experience + (mappedTeam as any).buchholzPoints = 0; + } + + return mappedTeam; + }); + + if (!revealAll) { + // Calculate dynamic Buchholz for the returned block + // This is efficient enough for pagination sizes (default is usually small, or all for ranking) + for (const team of teamsWithCounts) { + (team as any).buchholzPoints = + await this.matchService.calculateRevealedBuchholzPointsForTeam( + team.id, + eventId, + ); + } + } + + return teamsWithCounts as any; } async joinQueue(teamId: string) { From ba49a6214ea03eb07621836582df38737fe847d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Gro=C3=9Fmann?= Date: Fri, 6 Feb 2026 15:59:11 +0100 Subject: [PATCH 03/10] Revert "feat: added swiss round table" --- api/src/match/entites/match.entity.ts | 2 +- api/src/match/match.controller.ts | 13 +- api/src/match/match.service.ts | 63 +------- api/src/team/entities/team.entity.ts | 2 +- api/src/team/team.controller.ts | 32 ++-- api/src/team/team.service.ts | 146 +++++------------- frontend/app/actions/team.ts | 34 ++-- .../app/events/[id]/groups/GroupPhaseTabs.tsx | 77 --------- .../app/events/[id]/groups/RankingTable.tsx | 141 ----------------- frontend/app/events/[id]/groups/page.tsx | 36 ++--- .../events/[id]/teams/[teamId]/BackButton.tsx | 2 +- 11 files changed, 79 insertions(+), 469 deletions(-) delete mode 100644 frontend/app/events/[id]/groups/GroupPhaseTabs.tsx delete mode 100644 frontend/app/events/[id]/groups/RankingTable.tsx diff --git a/api/src/match/entites/match.entity.ts b/api/src/match/entites/match.entity.ts index 1fb4d5b8..298a7a9d 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 | null; + winner: TeamEntity; @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 6925e652..f6616d68 100644 --- a/api/src/match/match.controller.ts +++ b/api/src/match/match.controller.ts @@ -172,19 +172,8 @@ export class MatchController { @Get(":matchId") async getMatchById( @Param("matchId", ParseUUIDPipe) matchId: string, - @UserId() userId: string, - @Query("adminRevealQuery") adminRevealQuery: boolean, ): Promise { - return await this.matchService.getMatchById( - matchId, - { - teams: { - event: true, - }, - }, - userId, - Boolean(adminRevealQuery), - ); + return await this.matchService.getMatchById(matchId); } @UseGuards(JwtAuthGuard) diff --git a/api/src/match/match.service.ts b/api/src/match/match.service.ts index 9c24fa7a..9a3ceab2 100644 --- a/api/src/match/match.service.ts +++ b/api/src/match/match.service.ts @@ -1021,37 +1021,15 @@ export class MatchService { async getMatchById( matchId: string, relations: FindOptionsRelations = {}, - userId?: string, - adminReveal?: boolean, ): Promise { - const match = await this.matchRepository.findOneOrFail({ + return 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: [], - }; } - async revealMatch(matchId: string) { - await this.matchRepository.update(matchId, { + revealMatch(matchId: string) { + return this.matchRepository.update(matchId, { isRevealed: true, }); } @@ -1076,41 +1054,6 @@ export class MatchService { } } - async calculateRevealedBuchholzPointsForTeam( - teamId: string, - eventId: string, - ): Promise { - // A team's revealed Buchholz points is the sum of revealed scores of its opponents. - // We already have getFormerOpponents, but that's for ALL finished matches. - // For "revealed" Buchholz, we only care about Swiss phase. - - const matches = await this.matchRepository.find({ - where: { - teams: { id: teamId }, - phase: MatchPhase.SWISS, - state: MatchState.FINISHED, - }, - relations: { teams: true }, - }); - - let totalRevealedBuchholz = 0; - for (const match of matches) { - const opponent = match.teams.find((t) => t.id !== teamId); - if (opponent) { - // Opponent's revealed score = count of revealed wins in Swiss - const revealedWins = await this.matchRepository.count({ - where: { - winner: { id: opponent.id }, - isRevealed: true, - phase: MatchPhase.SWISS, - }, - }); - totalRevealedBuchholz += revealedWins; - } - } - return totalRevealedBuchholz; - } - getGlobalStats() { return this.matchStatsRepository .createQueryBuilder("match_stats") diff --git a/api/src/team/entities/team.entity.ts b/api/src/team/entities/team.entity.ts index ace7d327..ddfc8a5c 100644 --- a/api/src/team/entities/team.entity.ts +++ b/api/src/team/entities/team.entity.ts @@ -27,7 +27,7 @@ export class TeamEntity { @Column({ nullable: true }) repo: string; - @Column({ nullable: true, type: "timestamp" }) + @Column({nullable: true, type: "timestamp" }) startedRepoCreationAt: Date | null; @Column({ default: 0 }) diff --git a/api/src/team/team.controller.ts b/api/src/team/team.controller.ts index fb8b4c3f..ff7ef50f 100644 --- a/api/src/team/team.controller.ts +++ b/api/src/team/team.controller.ts @@ -35,19 +35,16 @@ export class TeamController { private readonly teamService: TeamService, private readonly userService: UserService, private readonly eventService: EventService, - ) {} + ) { } @Get(":id") getTeamById(@Param("id", new ParseUUIDPipe()) id: string) { 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, @@ -57,8 +54,6 @@ export class TeamController { searchName, sortDir, sortBy, - userId, - adminRevealQuery === "true", ); } @@ -139,13 +134,15 @@ export class TeamController { @EventId eventId: string, @Body() inviteUserDto: InviteUserDto, @Team() team: TeamEntity, - @UserId() userId: string, + @UserId() userId: string ) { - if (userId === inviteUserDto.userToInviteId) - throw new BadRequestException("You cannot invite yourself to a team."); + if(userId === inviteUserDto.userToInviteId) + throw new BadRequestException( + "You cannot invite yourself to a team.", + ) - if (await this.teamService.isTeamFull(team.id)) - throw new BadRequestException("This team is full."); + if(await this.teamService.isTeamFull(team.id)) + throw new BadRequestException("This team is full."); if ( await this.teamService.getTeamOfUserForEvent( eventId, @@ -178,14 +175,9 @@ export class TeamController { @EventId eventId: string, @Param("searchQuery") searchQuery: string, @Team() team: TeamEntity, - @UserId() userId: string, + @UserId() userId: string ) { - return this.userService.searchUsersForInvite( - eventId, - searchQuery, - team.id, - userId, - ); + return this.userService.searchUsersForInvite(eventId, searchQuery, team.id, userId); } @UseGuards(JwtAuthGuard) @@ -211,8 +203,8 @@ export class TeamController { if (!(await this.teamService.isUserInvitedToTeam(userId, teamId))) throw new BadRequestException("You are not invited to this team."); - if (await this.teamService.isTeamFull(teamId)) - throw new BadRequestException("This team is full."); + if(await this.teamService.isTeamFull(teamId)) + throw new BadRequestException("This team is full."); return this.teamService.acceptTeamInvite(userId, teamId); } diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index 38603316..4e3b7366 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -15,7 +15,6 @@ 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 { @@ -30,7 +29,7 @@ export class TeamService { private readonly matchService: MatchService, @InjectDataSource() private readonly dataSource: DataSource, - ) {} + ) { } logger = new Logger("TeamService"); @@ -335,8 +334,6 @@ export class TeamService { searchName?: string, searchDir?: string, sortBy?: string, - userId?: string, - adminReveal?: boolean, ): Promise< Array< TeamEntity & { @@ -344,55 +341,23 @@ 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"); - - if (revealAll) { - query.select([ + .andWhere("team.deletedAt IS NULL") + .select([ "team.id", "team.name", "team.locked", - "team.score", - "team.buchholzPoints", - "team.hadBye", + "team.repo", "team.queueScore", "team.createdAt", "team.updatedAt", - ]); - } 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", - ]) - .addSelect("COUNT(DISTINCT match.id)", "revealed_score"); - - // Buchholz points are harder to calculate in a single query without nested aggregation. - // We'll calculate them in JS for now or accept that for search it might be 0/placeholder if too complex. - // However, for the Ranking Table, we need them. - } - - query.addSelect("COUNT(DISTINCT user.id)", "user_count").groupBy("team.id"); + ]) + .addSelect("COUNT(user.id)", "userCount") + .groupBy("team.id"); if (searchName) { query.andWhere("team.name LIKE :searchName", { @@ -402,70 +367,27 @@ export class TeamService { if (sortBy) { const direction = searchDir?.toUpperCase() === "DESC" ? "DESC" : "ASC"; - let sortColumn = sortBy; - const validSortColumns = [ "name", "locked", - "score", + "repo", "queueScore", "createdAt", "updatedAt", ]; - - if (validSortColumns.includes(sortColumn)) { - if (!revealAll && sortColumn === "score") { - query.orderBy("revealed_score", direction as "ASC" | "DESC"); - } else { - query.orderBy(`team.${sortColumn}`, direction as "ASC" | "DESC"); - } + if (validSortColumns.includes(sortBy)) { + query.orderBy(`team.${sortBy}`, direction as "ASC" | "DESC"); } - - if (sortBy === "buchholzPoints") { - if (revealAll) { - query.orderBy("team.buchholzPoints", direction as "ASC" | "DESC"); - } else { - // If sorting by Buchholz and not revealed, we might just skip or sort by score - query.orderBy("revealed_score", direction as "ASC" | "DESC"); - } - } - if (sortBy === "membersCount") { - query.orderBy("COUNT(DISTINCT user.id)", direction as "ASC" | "DESC"); + query.orderBy("COUNT(user.id)", direction as "ASC" | "DESC"); } } const result = await query.getRawAndEntities(); - - const teamsWithCounts = result.entities.map((team, idx) => { - const raw = result.raw[idx]; - const mappedTeam = { - ...team, - userCount: parseInt(raw.user_count, 10), - }; - - if (!revealAll) { - (mappedTeam as any).score = parseInt(raw.revealed_score, 10); - // Buchholz points will be calculated below for consistent Ranking Table experience - (mappedTeam as any).buchholzPoints = 0; - } - - return mappedTeam; - }); - - if (!revealAll) { - // Calculate dynamic Buchholz for the returned block - // This is efficient enough for pagination sizes (default is usually small, or all for ranking) - for (const team of teamsWithCounts) { - (team as any).buchholzPoints = - await this.matchService.calculateRevealedBuchholzPointsForTeam( - team.id, - eventId, - ); - } - } - - return teamsWithCounts as any; + return result.entities.map((team, idx) => ({ + ...team, + userCount: parseInt(result.raw[idx].userCount, 10), + })); } async joinQueue(teamId: string) { @@ -569,22 +491,22 @@ export class TeamService { }); } - async isTeamFull(teamId: string) { - const team = await this.teamRepository.findOne({ - where: { - id: teamId, - }, - relations: { - event: true, - users: true, - }, - }); - const maxUsers = team?.event.maxTeamSize; - if (!maxUsers) return true; - if (!team?.users) return true; - - return team?.users.length >= maxUsers; - } + async isTeamFull(teamId: string) { + const team = await this.teamRepository.findOne({ + where: { + id: teamId, + }, + relations: { + event: true, + users: true + } + }); + const maxUsers = team?.event.maxTeamSize; + if (!maxUsers) return true; + if (!team?.users) return true; + + return team?.users.length >= maxUsers; + } async leaveQueue(teamId: string) { return this.teamRepository.update(teamId, { inQueue: false }); @@ -614,10 +536,12 @@ export class TeamService { }); for (const team of teams) { - if (!team.repo || !team.event) continue; + if (!team.repo || !team.event) + continue; for (const user of team.users) { - if (!user.username) continue; + if (!user.username) + continue; await this.githubApiService.addWritePermissions( user.username, diff --git a/frontend/app/actions/team.ts b/frontend/app/actions/team.ts index 1e785c7a..aa98f4bc 100644 --- a/frontend/app/actions/team.ts +++ b/frontend/app/actions/team.ts @@ -11,8 +11,6 @@ export interface Team { repo: string; inQueue: boolean; score: number; - buchholzPoints: number; - hadBye: boolean; queueScore: number; locked?: boolean; created?: string; @@ -69,18 +67,16 @@ 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, - buchholzPoints: team.buchholzPoints || 0, - hadBye: team.hadBye || false, - 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, + queueScore: team.queueScore, + createdAt: team.createdAt, + inQueue: team.inQueue, + updatedAt: team.updatedAt, + } : null; } @@ -91,7 +87,8 @@ 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 { @@ -100,8 +97,6 @@ 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, @@ -211,8 +206,6 @@ export async function getTeamsForEventTable( | "name" | "createdAt" | "membersCount" - | "score" - | "buchholzPoints" | "queueScore" | undefined = "name", sortDirection: "asc" | "desc" = "asc", @@ -232,9 +225,6 @@ export async function getTeamsForEventTable( name: team.name, repo: team.repo || "", membersCount: team.userCount, - 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/events/[id]/groups/GroupPhaseTabs.tsx b/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx deleted file mode 100644 index 847d4862..00000000 --- a/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"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 deleted file mode 100644 index 42535b2f..00000000 --- a/frontend/app/events/[id]/groups/RankingTable.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"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) => { - if (b.score !== a.score) return b.score - a.score; - return b.buchholzPoints - a.buchholzPoints; - }); - - 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.toFixed(1)} - - - {team.buchholzPoints.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/page.tsx b/frontend/app/events/[id]/groups/page.tsx index eae95c7d..ee4e15e3 100644 --- a/frontend/app/events/[id]/groups/page.tsx +++ b/frontend/app/events/[id]/groups/page.tsx @@ -1,12 +1,8 @@ import { isActionError } from "@/app/actions/errors"; import { isEventAdmin } from "@/app/actions/event"; -import { getTeamsForEventTable } from "@/app/actions/team"; -import { - getSwissMatches, - getTournamentTeamCount, -} from "@/app/actions/tournament"; +import { getSwissMatches } from "@/app/actions/tournament"; import Actions from "@/app/events/[id]/groups/actions"; -import GroupPhaseTabs from "@/app/events/[id]/groups/GroupPhaseTabs"; +import GraphView from "@/app/events/[id]/groups/graphView"; export const metadata = { title: "Group Phase", @@ -19,23 +15,18 @@ export default async function page({ searchParams, }: { params: Promise<{ id: string }>; - searchParams: Promise<{ adminReveal?: string; tab?: string }>; + searchParams: Promise<{ adminReveal?: string }>; }) { const eventId = (await params).id; const isAdminView = (await searchParams).adminReveal === "true"; - const [matches, eventAdmin, teams, advancementCount] = await Promise.all([ - getSwissMatches(eventId, isAdminView), - isEventAdmin(eventId), - getTeamsForEventTable(eventId, undefined, "score", "desc"), - getTournamentTeamCount(eventId), - ]); - + const matches = await getSwissMatches(eventId, isAdminView); + const eventAdmin = await isEventAdmin(eventId); if (isActionError(eventAdmin)) { throw new Error("Failed to verify admin status"); } return ( -
+

@@ -53,14 +44,13 @@ export default async function page({ )}

- +
+ +
); } diff --git a/frontend/app/events/[id]/teams/[teamId]/BackButton.tsx b/frontend/app/events/[id]/teams/[teamId]/BackButton.tsx index c4747c3c..8eadc99a 100644 --- a/frontend/app/events/[id]/teams/[teamId]/BackButton.tsx +++ b/frontend/app/events/[id]/teams/[teamId]/BackButton.tsx @@ -14,7 +14,7 @@ export default function BackButton() { variant="ghost" aria-label="Back to teams list" onClick={() => { - router.back(); + router.push(`/events/${eventId}/teams`); }} > From 2c98e93950849177c113d72b36b53b869348f053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Gro=C3=9Fmann?= Date: Tue, 17 Feb 2026 20:41:58 +0100 Subject: [PATCH 04/10] =?UTF-8?q?Reapply=20"feat:=20Refactor=20admin=20vie?= =?UTF-8?q?w=20toggle=20into=20a=20dedicated=20component=20and=20enhan?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c8700859bb0aed65a742c0769c26861077280ffa. --- api/README.md | 59 ++-- api/package.json | 3 +- api/src/scripts/seed-users-teams.ts | 106 ++++++ frontend/app/events/[id]/bracket/actions.tsx | 27 +- .../app/events/[id]/bracket/graphView.tsx | 325 ++++++++++-------- frontend/app/events/[id]/bracket/page.tsx | 36 +- frontend/app/events/[id]/groups/actions.tsx | 27 +- frontend/app/events/[id]/groups/graphView.tsx | 61 ++-- frontend/app/events/[id]/groups/page.tsx | 37 +- frontend/components/match/MatchNode.tsx | 50 ++- .../components/team/TeamCreationSection.tsx | 20 +- 11 files changed, 510 insertions(+), 241 deletions(-) create mode 100644 api/src/scripts/seed-users-teams.ts 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/scripts/seed-users-teams.ts b/api/src/scripts/seed-users-teams.ts new file mode 100644 index 00000000..df36f6db --- /dev/null +++ b/api/src/scripts/seed-users-teams.ts @@ -0,0 +1,106 @@ +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!"); + + 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) { + console.error(`Event with ID ${eventId} not found`); + await dataSource.destroy(); + process.exit(1); + } + + 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; + + teams.push(team); + } + + await teamRepository.save(teams); + console.log(`Successfully saved 30 teams and assigned users`); + + console.log("Seeding completed successfully!"); + await dataSource.destroy(); +} + +bootstrap().catch((err) => { + console.error("Error seeding data:", err); + process.exit(1); +}); 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..643e7565 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 + 1, 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 - 1; + 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 * 2, + }; + + 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]/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..ee4e15e3 100644 --- a/frontend/app/events/[id]/groups/page.tsx +++ b/frontend/app/events/[id]/groups/page.tsx @@ -26,20 +26,31 @@ export default async function page({ } 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..d1c85859 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 && ( - - {formatTeamName(team.name)} +
+ { + e.stopPropagation(); + if (team.id) { + 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}
From 57115c60af642d0a5cb3e181308c169882b64a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Gro=C3=9Fmann?= Date: Tue, 17 Feb 2026 20:42:32 +0100 Subject: [PATCH 05/10] Revert "Revert "feat: added swiss round table"" This reverts commit ba49a621 --- api/src/match/entites/match.entity.ts | 2 +- api/src/match/match.controller.ts | 13 +- api/src/match/match.service.ts | 63 +++++++- api/src/team/team.controller.ts | 5 + api/src/team/team.service.ts | 106 +++++++++++-- frontend/app/actions/team.ts | 34 +++-- .../app/events/[id]/groups/GroupPhaseTabs.tsx | 77 ++++++++++ .../app/events/[id]/groups/RankingTable.tsx | 141 ++++++++++++++++++ frontend/app/events/[id]/groups/page.tsx | 36 +++-- 9 files changed, 433 insertions(+), 44 deletions(-) create mode 100644 frontend/app/events/[id]/groups/GroupPhaseTabs.tsx create mode 100644 frontend/app/events/[id]/groups/RankingTable.tsx 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..6925e652 100644 --- a/api/src/match/match.controller.ts +++ b/api/src/match/match.controller.ts @@ -172,8 +172,19 @@ export class MatchController { @Get(":matchId") async getMatchById( @Param("matchId", ParseUUIDPipe) matchId: string, + @UserId() userId: string, + @Query("adminRevealQuery") adminRevealQuery: boolean, ): Promise { - return await this.matchService.getMatchById(matchId); + return await this.matchService.getMatchById( + matchId, + { + teams: { + event: true, + }, + }, + userId, + Boolean(adminRevealQuery), + ); } @UseGuards(JwtAuthGuard) diff --git a/api/src/match/match.service.ts b/api/src/match/match.service.ts index d19f1daf..8263ea2a 100644 --- a/api/src/match/match.service.ts +++ b/api/src/match/match.service.ts @@ -1026,15 +1026,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 +1081,41 @@ export class MatchService { } } + async calculateRevealedBuchholzPointsForTeam( + teamId: string, + eventId: string, + ): Promise { + // A team's revealed Buchholz points is the sum of revealed scores of its opponents. + // We already have getFormerOpponents, but that's for ALL finished matches. + // For "revealed" Buchholz, we only care about Swiss phase. + + const matches = await this.matchRepository.find({ + where: { + teams: { id: teamId }, + phase: MatchPhase.SWISS, + state: MatchState.FINISHED, + }, + relations: { teams: true }, + }); + + let totalRevealedBuchholz = 0; + for (const match of matches) { + const opponent = match.teams.find((t) => t.id !== teamId); + if (opponent) { + // Opponent's revealed score = count of revealed wins in Swiss + const revealedWins = await this.matchRepository.count({ + where: { + winner: { id: opponent.id }, + isRevealed: true, + phase: MatchPhase.SWISS, + }, + }); + totalRevealedBuchholz += revealedWins; + } + } + return totalRevealedBuchholz; + } + getGlobalStats() { return this.matchStatsRepository .createQueryBuilder("match_stats") 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..ed904e67 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 { @@ -370,6 +371,8 @@ export class TeamService { searchName?: string, searchDir?: string, sortBy?: string, + userId?: string, + adminReveal?: boolean, ): Promise< Array< TeamEntity & { @@ -377,23 +380,55 @@ 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", + ]) + .addSelect("COUNT(DISTINCT match.id)", "revealed_score"); + + // Buchholz points are harder to calculate in a single query without nested aggregation. + // We'll calculate them in JS for now or accept that for search it might be 0/placeholder if too complex. + // However, for the Ranking Table, we need them. + } + + query.addSelect("COUNT(DISTINCT user.id)", "user_count").groupBy("team.id"); if (searchName) { query.andWhere("team.name LIKE :searchName", { @@ -403,27 +438,70 @@ export class TeamService { if (sortBy) { const direction = searchDir?.toUpperCase() === "DESC" ? "DESC" : "ASC"; + let sortColumn = sortBy; + const validSortColumns = [ "name", "locked", - "repo", + "score", "queueScore", "createdAt", "updatedAt", ]; - if (validSortColumns.includes(sortBy)) { - query.orderBy(`team.${sortBy}`, direction as "ASC" | "DESC"); + + if (validSortColumns.includes(sortColumn)) { + if (!revealAll && sortColumn === "score") { + query.orderBy("revealed_score", direction as "ASC" | "DESC"); + } else { + query.orderBy(`team.${sortColumn}`, direction as "ASC" | "DESC"); + } + } + + if (sortBy === "buchholzPoints") { + if (revealAll) { + query.orderBy("team.buchholzPoints", direction as "ASC" | "DESC"); + } else { + // If sorting by Buchholz and not revealed, we might just skip or sort by score + query.orderBy("revealed_score", 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), - })); + + const teamsWithCounts = result.entities.map((team, idx) => { + const raw = result.raw[idx]; + const mappedTeam = { + ...team, + userCount: parseInt(raw.user_count, 10), + }; + + if (!revealAll) { + (mappedTeam as any).score = parseInt(raw.revealed_score, 10); + // Buchholz points will be calculated below for consistent Ranking Table experience + (mappedTeam as any).buchholzPoints = 0; + } + + return mappedTeam; + }); + + if (!revealAll) { + // Calculate dynamic Buchholz for the returned block + // This is efficient enough for pagination sizes (default is usually small, or all for ranking) + for (const team of teamsWithCounts) { + (team as any).buchholzPoints = + await this.matchService.calculateRevealedBuchholzPointsForTeam( + team.id, + eventId, + ); + } + } + + return teamsWithCounts as any; } async joinQueue(teamId: string) { diff --git a/frontend/app/actions/team.ts b/frontend/app/actions/team.ts index aa98f4bc..1e785c7a 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,6 +211,8 @@ export async function getTeamsForEventTable( | "name" | "createdAt" | "membersCount" + | "score" + | "buchholzPoints" | "queueScore" | undefined = "name", sortDirection: "asc" | "desc" = "asc", @@ -225,6 +232,9 @@ export async function getTeamsForEventTable( name: team.name, repo: team.repo || "", membersCount: team.userCount, + 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/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..42535b2f --- /dev/null +++ b/frontend/app/events/[id]/groups/RankingTable.tsx @@ -0,0 +1,141 @@ +"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) => { + if (b.score !== a.score) return b.score - a.score; + return b.buchholzPoints - a.buchholzPoints; + }); + + 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.toFixed(1)} + + + {team.buchholzPoints.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/page.tsx b/frontend/app/events/[id]/groups/page.tsx index ee4e15e3..eae95c7d 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,18 +19,23 @@ 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"), + getTournamentTeamCount(eventId), + ]); + if (isActionError(eventAdmin)) { throw new Error("Failed to verify admin status"); } return ( -
+

@@ -44,13 +53,14 @@ export default async function page({ )}

-
- -
+
); } From d28a9e9a13edeca6f2ae16df0d81bcc04d875d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Gro=C3=9Fmann?= Date: Tue, 17 Feb 2026 21:22:20 +0100 Subject: [PATCH 06/10] feat: again working tournament system --- AGENTS.md | 91 ++++++++ api/src/match/match.controller.ts | 15 ++ api/src/match/match.service.ts | 202 ++++++++++++++---- api/src/scripts/seed-users-teams.ts | 2 + api/src/team/team.service.ts | 9 + frontend/app/actions/tournament.ts | 9 + .../app/events/[id]/bracket/graphView.tsx | 6 +- .../app/events/[id]/dashboard/dashboard.tsx | 58 +++++ 8 files changed, 342 insertions(+), 50 deletions(-) create mode 100644 AGENTS.md 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/api/src/match/match.controller.ts b/api/src/match/match.controller.ts index 6925e652..d404f3b3 100644 --- a/api/src/match/match.controller.ts +++ b/api/src/match/match.controller.ts @@ -169,6 +169,21 @@ 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); + } + @Get(":matchId") async getMatchById( @Param("matchId", ParseUUIDPipe) matchId: string, diff --git a/api/src/match/match.service.ts b/api/src/match/match.service.ts index 8263ea2a..cec59934 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,16 @@ 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}.`, + `Event ${event.name} has finished round ${finishedRound}.`, ); - if (event.currentRound + 1 >= this.getMaxSwissRounds(event.teams.length)) { + + if ( + finishedRound + 1 >= + this.getMaxSwissRounds(event.teams.length) + ) { this.logger.log( `Event ${event.name} has reached the maximum Swiss rounds.`, ); @@ -291,9 +345,10 @@ 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}.`, + `Event ${event.name} has finished round ${finishedRound}.`, ); await this.createNextTournamentMatches(event.id); } @@ -435,25 +490,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); @@ -531,6 +582,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 +624,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 +636,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 +686,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); + } } } @@ -1081,6 +1165,30 @@ 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, diff --git a/api/src/scripts/seed-users-teams.ts b/api/src/scripts/seed-users-teams.ts index df36f6db..d89120f6 100644 --- a/api/src/scripts/seed-users-teams.ts +++ b/api/src/scripts/seed-users-teams.ts @@ -89,6 +89,8 @@ async function bootstrap() { // 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); } diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index ed904e67..44b529b1 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -667,4 +667,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/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/graphView.tsx b/frontend/app/events/[id]/bracket/graphView.tsx index 643e7565..1b294a2f 100644 --- a/frontend/app/events/[id]/bracket/graphView.tsx +++ b/frontend/app/events/[id]/bracket/graphView.tsx @@ -61,7 +61,7 @@ export default function GraphView({ const placeholderMatch: Match = { id: ``, isRevealed: false, - round: round + 1, + round: round, state: "PLANNED" as any, phase: "ELIMINATION" as any, createdAt: new Date().toISOString(), @@ -105,7 +105,7 @@ export default function GraphView({ const roundKeys = Array.from(matchesByRound.keys()).sort((a, b) => a - b); for (const round of roundKeys) { - const roundIndex = round - 1; + const roundIndex = round; const roundMatches = matchesByRound.get(round) || []; const isLastRound = roundIndex === lastRoundIndex; @@ -159,7 +159,7 @@ export default function GraphView({ const placementId = placementMatch.id ?? `placement-${roundIndex}`; const placementCoord = { x: roundIndex * ROUND_SPACING, - y: spacing / 2 + VERTICAL_SPACING * 2, + y: spacing / 2 + VERTICAL_SPACING * 1.5, }; newNodes.push({ 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 +
From 99c78fe8d20ac4594ee55f2ff7debe2a8a6cb9b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Gro=C3=9Fmann?= Date: Tue, 17 Feb 2026 21:52:35 +0100 Subject: [PATCH 07/10] fix: display of buchholz and byes --- .agent/rules/website.md | 104 +++++++++++++++ TOURNAMENT_LOGIC.md | 67 ++++++++++ api/src/match/match.service.ts | 119 ++++++++++++------ api/src/team/team.service.ts | 80 +++++++----- frontend/app/actions/team.ts | 10 +- .../app/events/[id]/groups/RankingTable.tsx | 13 +- frontend/app/events/[id]/groups/page.tsx | 2 +- 7 files changed, 313 insertions(+), 82 deletions(-) create mode 100644 .agent/rules/website.md create mode 100644 TOURNAMENT_LOGIC.md 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/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/src/match/match.service.ts b/api/src/match/match.service.ts index cec59934..5adc3ed4 100644 --- a/api/src/match/match.service.ts +++ b/api/src/match/match.service.ts @@ -288,14 +288,9 @@ export class MatchService { const finishedRound = event.currentRound; await this.eventService.increaseEventRound(evenId); - this.logger.log( - `Event ${event.name} has finished round ${finishedRound}.`, - ); + this.logger.log(`Event ${event.name} has finished round ${finishedRound}.`); - if ( - finishedRound + 1 >= - this.getMaxSwissRounds(event.teams.length) - ) { + if (finishedRound + 1 >= this.getMaxSwissRounds(event.teams.length)) { this.logger.log( `Event ${event.name} has reached the maximum Swiss rounds.`, ); @@ -347,9 +342,7 @@ export class MatchService { const finishedRound = event.currentRound; await this.eventService.increaseEventRound(event.id); - this.logger.log( - `Event ${event.name} has finished round ${finishedRound}.`, - ); + this.logger.log(`Event ${event.name} has finished round ${finishedRound}.`); await this.createNextTournamentMatches(event.id); } @@ -540,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( @@ -1193,35 +1185,84 @@ export class MatchService { teamId: string, eventId: string, ): Promise { - // A team's revealed Buchholz points is the sum of revealed scores of its opponents. - // We already have getFormerOpponents, but that's for ALL finished matches. - // For "revealed" Buchholz, we only care about Swiss phase. + const results = await this.calculateBuchholzPointsForTeams( + [teamId], + eventId, + true, + ); + return results.get(teamId) || 0; + } - const matches = await this.matchRepository.find({ - where: { - teams: { id: teamId }, - phase: MatchPhase.SWISS, - state: MatchState.FINISHED, - }, - relations: { teams: true }, + 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"], }); - let totalRevealedBuchholz = 0; - for (const match of matches) { - const opponent = match.teams.find((t) => t.id !== teamId); - if (opponent) { - // Opponent's revealed score = count of revealed wins in Swiss - const revealedWins = await this.matchRepository.count({ - where: { - winner: { id: opponent.id }, - isRevealed: true, - phase: MatchPhase.SWISS, - }, - }); - totalRevealedBuchholz += revealedWins; + 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); } } - return totalRevealedBuchholz; + + // 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() { diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index 44b529b1..d07b78bf 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -420,12 +420,9 @@ export class TeamService { "team.queueScore", "team.createdAt", "team.updatedAt", + "team.score", ]) - .addSelect("COUNT(DISTINCT match.id)", "revealed_score"); - - // Buchholz points are harder to calculate in a single query without nested aggregation. - // We'll calculate them in JS for now or accept that for search it might be 0/placeholder if too complex. - // However, for the Ranking Table, we need them. + .addSelect("COUNT(DISTINCT match.id)", "revealed_match_wins"); } query.addSelect("COUNT(DISTINCT user.id)", "user_count").groupBy("team.id"); @@ -451,7 +448,7 @@ export class TeamService { if (validSortColumns.includes(sortColumn)) { if (!revealAll && sortColumn === "score") { - query.orderBy("revealed_score", direction as "ASC" | "DESC"); + query.orderBy("revealed_match_wins", direction as "ASC" | "DESC"); } else { query.orderBy(`team.${sortColumn}`, direction as "ASC" | "DESC"); } @@ -461,8 +458,7 @@ export class TeamService { if (revealAll) { query.orderBy("team.buchholzPoints", direction as "ASC" | "DESC"); } else { - // If sorting by Buchholz and not revealed, we might just skip or sort by score - query.orderBy("revealed_score", direction as "ASC" | "DESC"); + query.orderBy("revealed_match_wins", direction as "ASC" | "DESC"); } } @@ -473,35 +469,51 @@ export class TeamService { const result = await query.getRawAndEntities(); - const teamsWithCounts = result.entities.map((team, idx) => { - const raw = result.raw[idx]; - const mappedTeam = { - ...team, - userCount: parseInt(raw.user_count, 10), - }; - - if (!revealAll) { - (mappedTeam as any).score = parseInt(raw.revealed_score, 10); - // Buchholz points will be calculated below for consistent Ranking Table experience - (mappedTeam as any).buchholzPoints = 0; - } + // 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, + ); - return mappedTeam; - }); + // 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) { - // Calculate dynamic Buchholz for the returned block - // This is efficient enough for pagination sizes (default is usually small, or all for ranking) - for (const team of teamsWithCounts) { - (team as any).buchholzPoints = - await this.matchService.calculateRevealedBuchholzPointsForTeam( - team.id, - eventId, - ); - } - } + 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 as any; + return teamsWithCounts; } async joinQueue(teamId: string) { diff --git a/frontend/app/actions/team.ts b/frontend/app/actions/team.ts index 1e785c7a..588ff340 100644 --- a/frontend/app/actions/team.ts +++ b/frontend/app/actions/team.ts @@ -216,6 +216,7 @@ export async function getTeamsForEventTable( | "queueScore" | undefined = "name", sortDirection: "asc" | "desc" = "asc", + adminReveal: boolean = false, ) { const teams = ( await axiosInstance.get(`team/event/${eventId}/`, { @@ -223,6 +224,7 @@ export async function getTeamsForEventTable( searchName: searchTeamName, sortBy: sortColumn, sortDir: sortDirection, + adminRevealQuery: adminReveal, }, }) ).data; @@ -232,10 +234,10 @@ export async function getTeamsForEventTable( name: team.name, repo: team.repo || "", membersCount: team.userCount, - score: team.score || 0, - buchholzPoints: team.buchholzPoints || 0, - hadBye: team.hadBye || false, - 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/events/[id]/groups/RankingTable.tsx b/frontend/app/events/[id]/groups/RankingTable.tsx index 42535b2f..bdefbe62 100644 --- a/frontend/app/events/[id]/groups/RankingTable.tsx +++ b/frontend/app/events/[id]/groups/RankingTable.tsx @@ -30,8 +30,13 @@ export default function RankingTable({ }: RankingTableProps) { // Sort teams by score (desc), then buchholzPoints (desc) const sortedTeams = [...teams].sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return b.buchholzPoints - a.buchholzPoints; + 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) => { @@ -82,10 +87,10 @@ export default function RankingTable({
- {team.score.toFixed(1)} + {(team.score ?? 0).toFixed(1)} - {team.buchholzPoints.toFixed(1)} + {(team.buchholzPoints ?? 0).toFixed(1)} diff --git a/frontend/app/events/[id]/groups/page.tsx b/frontend/app/events/[id]/groups/page.tsx index eae95c7d..6c79a1e0 100644 --- a/frontend/app/events/[id]/groups/page.tsx +++ b/frontend/app/events/[id]/groups/page.tsx @@ -26,7 +26,7 @@ export default async function page({ const [matches, eventAdmin, teams, advancementCount] = await Promise.all([ getSwissMatches(eventId, isAdminView), isEventAdmin(eventId), - getTeamsForEventTable(eventId, undefined, "score", "desc"), + getTeamsForEventTable(eventId, undefined, "score", "desc", isAdminView), getTournamentTeamCount(eventId), ]); From 203956f98de5b3fc9981f2d9ca66b7814068ccc1 Mon Sep 17 00:00:00 2001 From: PaulicStudios Date: Thu, 19 Feb 2026 19:05:52 +0100 Subject: [PATCH 08/10] feat: Utilize ParseBoolPipe for `adminRevealQuery` in match GET endpoints and secure `getMatchById` with `JwtAuthGuard`. --- api/src/match/match.controller.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/api/src/match/match.controller.ts b/api/src/match/match.controller.ts index d404f3b3..8d1a9af5 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", ParseBoolPipe) 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", ParseBoolPipe) adminRevealQuery: boolean, ) { return this.matchService.getTournamentMatches( eventId, @@ -184,11 +185,12 @@ export class MatchController { return this.matchService.cleanupMatchesInPhase(eventId, phase as any); } + @UseGuards(JwtAuthGuard) @Get(":matchId") async getMatchById( @Param("matchId", ParseUUIDPipe) matchId: string, @UserId() userId: string, - @Query("adminRevealQuery") adminRevealQuery: boolean, + @Query("adminRevealQuery", ParseBoolPipe) adminRevealQuery: boolean, ): Promise { return await this.matchService.getMatchById( matchId, @@ -198,7 +200,7 @@ export class MatchController { }, }, userId, - Boolean(adminRevealQuery), + adminRevealQuery, ); } From 7e71a9b3633e466ff0e283c63149515c7271ac40 Mon Sep 17 00:00:00 2001 From: PaulicStudios Date: Thu, 19 Feb 2026 19:06:51 +0100 Subject: [PATCH 09/10] refactor: ensure database connection is always closed and improve error handling in seed script. --- api/src/scripts/seed-users-teams.ts | 128 ++++++++++++++-------------- 1 file changed, 65 insertions(+), 63 deletions(-) diff --git a/api/src/scripts/seed-users-teams.ts b/api/src/scripts/seed-users-teams.ts index d89120f6..cdef214b 100644 --- a/api/src/scripts/seed-users-teams.ts +++ b/api/src/scripts/seed-users-teams.ts @@ -32,74 +32,76 @@ async function bootstrap() { entities: [join(__dirname, "..", "**", "*.entity.ts")], } as DataSourceOptions); + await dataSource.initialize(); console.log("Database connected!"); - 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) { - console.error(`Event with ID ${eventId} not found`); + 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(); - process.exit(1); - } - - 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!"); - await dataSource.destroy(); } bootstrap().catch((err) => { From a66f1d31f32486eef99a4aac30c39150cede316c Mon Sep 17 00:00:00 2001 From: PaulicStudios Date: Thu, 19 Feb 2026 19:17:12 +0100 Subject: [PATCH 10/10] feat: Add Buchholz points sorting for teams, make `adminRevealQuery` optional in match endpoints, and ensure `eventId` exists for team navigation. --- api/src/match/match.controller.ts | 8 ++++---- api/src/team/team.service.ts | 21 +++++++++++---------- frontend/components/match/MatchNode.tsx | 11 +++++------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/api/src/match/match.controller.ts b/api/src/match/match.controller.ts index 8d1a9af5..5ef9bc5d 100644 --- a/api/src/match/match.controller.ts +++ b/api/src/match/match.controller.ts @@ -31,7 +31,7 @@ export class MatchController { getSwissMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @UserId() userId: string, - @Query("adminRevealQuery", ParseBoolPipe) adminRevealQuery: boolean, + @Query("adminRevealQuery", new ParseBoolPipe({ optional: true })) adminRevealQuery: boolean, ) { return this.matchService.getSwissMatches( eventId, @@ -81,7 +81,7 @@ export class MatchController { getTournamentMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @UserId() userId: string, - @Query("adminRevealQuery", ParseBoolPipe) adminRevealQuery: boolean, + @Query("adminRevealQuery", new ParseBoolPipe({ optional: true })) adminRevealQuery: boolean, ) { return this.matchService.getTournamentMatches( eventId, @@ -190,7 +190,7 @@ export class MatchController { async getMatchById( @Param("matchId", ParseUUIDPipe) matchId: string, @UserId() userId: string, - @Query("adminRevealQuery", ParseBoolPipe) adminRevealQuery: boolean, + @Query("adminRevealQuery", new ParseBoolPipe({ optional: true })) adminRevealQuery: boolean, ): Promise { return await this.matchService.getMatchById( matchId, @@ -200,7 +200,7 @@ export class MatchController { }, }, userId, - adminRevealQuery, + adminRevealQuery ?? false, ); } diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index d07b78bf..ccdd432a 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -36,7 +36,7 @@ export class TeamService { private readonly matchService: MatchService, @InjectDataSource() private readonly dataSource: DataSource, - ) {} + ) { } logger = new Logger("TeamService"); @@ -444,24 +444,25 @@ export class TeamService { "queueScore", "createdAt", "updatedAt", + "buchholzPoints", ]; if (validSortColumns.includes(sortColumn)) { - if (!revealAll && sortColumn === "score") { + 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 === "buchholzPoints") { - if (revealAll) { - query.orderBy("team.buchholzPoints", direction as "ASC" | "DESC"); - } else { - query.orderBy("revealed_match_wins", direction as "ASC" | "DESC"); - } - } - if (sortBy === "membersCount") { query.orderBy("COUNT(DISTINCT user.id)", direction as "ASC" | "DESC"); } diff --git a/frontend/components/match/MatchNode.tsx b/frontend/components/match/MatchNode.tsx index d1c85859..66703ea5 100644 --- a/frontend/components/match/MatchNode.tsx +++ b/frontend/components/match/MatchNode.tsx @@ -150,21 +150,20 @@ function MatchNode({ data }: MatchNodeProps) { {/* Teams */}
{match.teams && - match.state === MatchState.FINISHED && - match.teams.length > 0 ? ( + match.state === MatchState.FINISHED && + match.teams.length > 0 ? ( match.teams.map((team, index) => (
{ e.stopPropagation(); - if (team.id) { + if (team.id && eventId) { router.push(`/events/${eventId}/teams/${team.id}`); } }}