diff --git a/.agent/rules/website.md b/.agent/rules/website.md
index cfbe3b5e..bb643232 100644
--- a/.agent/rules/website.md
+++ b/.agent/rules/website.md
@@ -23,44 +23,18 @@ This repository is a monorepo containing multiple services. Please follow these
### `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
diff --git a/frontend/app/events/[id]/bracket/BracketRankingTable.tsx b/frontend/app/events/[id]/bracket/BracketRankingTable.tsx
new file mode 100644
index 00000000..e58e463f
--- /dev/null
+++ b/frontend/app/events/[id]/bracket/BracketRankingTable.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+import type { Team } from "@/app/actions/team";
+import type { Match } from "@/app/actions/tournament-model";
+import { Award, Medal, Trophy } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo } from "react";
+import { MatchHistoryBadges } from "@/components/match/MatchHistoryBadges";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+
+interface RankingTableProps {
+ teams: Team[];
+ matches: Match[];
+ eventId: string;
+ isEventAdmin?: boolean;
+}
+
+export default function BracketRankingTable({
+ teams,
+ matches,
+ eventId,
+ isEventAdmin,
+}: RankingTableProps) {
+ const router = useRouter();
+
+ const searchParams = useSearchParams();
+ const shouldReveal
+ = isEventAdmin && searchParams.get("adminReveal") === "true";
+
+ const revealedMatches = useMemo(
+ () => matches.filter(m => m.isRevealed || shouldReveal),
+ [matches, shouldReveal],
+ );
+ const maxRound = useMemo(
+ () => Math.max(...matches.map(m => m.round), 1),
+ [matches],
+ );
+
+ const teamStatsMap = useMemo(() => {
+ const map = new Map<
+ string,
+ {
+ highestRound: number;
+ actualRank: number;
+ history: { id: string; result: string }[];
+ hasMatches: boolean;
+ }
+ >();
+ for (const team of teams) {
+ const teamId = team.id;
+ const teamMatches = revealedMatches.filter(m =>
+ m.teams.some(t => t.id === teamId),
+ );
+
+ const highestRound = Math.max(...teamMatches.map(m => m.round), 0);
+ const lastMatch = teamMatches.find(m => m.round === highestRound);
+
+ const isWinner = lastMatch?.winner?.id === teamId;
+ const hasWinner = !!lastMatch?.winner;
+
+ let actualRank = 0;
+ if (highestRound === maxRound && highestRound > 0) {
+ if (lastMatch?.isPlacementMatch) {
+ actualRank = isWinner ? 3 : hasWinner ? 4 : 3;
+ }
+ else {
+ actualRank = isWinner ? 1 : hasWinner ? 2 : 1;
+ }
+ }
+ else {
+ const effectiveRound = isWinner ? highestRound + 1 : highestRound;
+ actualRank = 2 ** (maxRound - effectiveRound) + 1;
+ }
+
+ const history = teamMatches
+ .filter(m => m.state === "FINISHED")
+ .sort((a, b) => a.round - b.round)
+ .map(m => ({
+ id: m.id!,
+ result: m.winner ? (m.winner.id === teamId ? "W" : "L") : "T",
+ }));
+
+ map.set(teamId, {
+ highestRound,
+ actualRank,
+ history,
+ hasMatches: teamMatches.length > 0,
+ });
+ }
+ return map;
+ }, [revealedMatches, maxRound, teams]);
+
+ const getTeamStats = (teamId: string) => teamStatsMap.get(teamId)!;
+
+ const swissRankMap = new Map(
+ [...teams]
+ .sort((a, b) =>
+ b.score !== a.score
+ ? b.score - a.score
+ : b.buchholzPoints - a.buchholzPoints,
+ )
+ .map((t, i) => [t.id, i + 1]),
+ );
+
+ const sortedTeams = teams
+ .map(team => ({
+ ...team,
+ swissRank: swissRankMap.get(team.id),
+ ...getTeamStats(team.id),
+ }))
+ .filter(team => team.hasMatches)
+ .sort((a, b) => {
+ if (a.actualRank !== b.actualRank)
+ return a.actualRank - b.actualRank;
+ if (b.score !== a.score)
+ return b.score - a.score;
+ return b.buchholzPoints - a.buchholzPoints;
+ });
+
+ return (
+
+
+
+
+
+ Standing
+
+ Swiss Rank
+
+ Name
+ Match History
+
+
+
+ {sortedTeams.length === 0
+ ? (
+
+
+ Matches will appear here once the bracket starts.
+
+
+ )
+ : (
+ sortedTeams.map((team) => {
+ const rank = team.actualRank;
+ const isWinner = rank === 1;
+ const isFinalist = rank === 2;
+ const isSemi = rank === 3;
+
+ return (
+
+ router.push(`/events/${eventId}/teams/${team.id}`)}
+ >
+
+
+ {isWinner
+ ? (
+
+ )
+ : isFinalist
+ ? (
+
+ )
+ : isSemi
+ ? (
+
+ )
+ : (
+
{rank}
+ )}
+
+
+
+ {team.swissRank}
+
+ {team.name}
+
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/app/events/[id]/bracket/BracketTabs.tsx b/frontend/app/events/[id]/bracket/BracketTabs.tsx
new file mode 100644
index 00000000..768d8650
--- /dev/null
+++ b/frontend/app/events/[id]/bracket/BracketTabs.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import type { Team } from "@/app/actions/team";
+import type { Match } from "@/app/actions/tournament-model";
+import { BarChart3, Network } from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useTabParam } from "@/hooks/useTabParam";
+import BracketRankingTable from "./BracketRankingTable";
+import GraphView from "./graphView";
+
+interface BracketTabsProps {
+ eventId: string;
+ matches: Match[];
+ teams: Team[];
+ isEventAdmin: boolean;
+ teamCount: number;
+}
+
+export default function BracketTabs({
+ eventId,
+ matches,
+ teams,
+ isEventAdmin,
+ teamCount,
+}: BracketTabsProps) {
+ const { currentTab, onTabChange } = useTabParam("graph");
+
+ return (
+
+
+
+
+
+ Graph
+
+
+
+ Ranking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/events/[id]/bracket/graphView.tsx b/frontend/app/events/[id]/bracket/graphView.tsx
index 3d91da3a..54a69533 100644
--- a/frontend/app/events/[id]/bracket/graphView.tsx
+++ b/frontend/app/events/[id]/bracket/graphView.tsx
@@ -139,6 +139,7 @@ export default function GraphView({
height: MATCH_HEIGHT,
showTargetHandle: roundIndex > 0,
showSourceHandle: roundIndex < lastRoundIndex,
+ hideScore: true,
onClick: (clickedMatch: Match) => {
if (
(match.state === MatchState.FINISHED || isEventAdmin)
@@ -173,6 +174,7 @@ export default function GraphView({
height: MATCH_HEIGHT,
showTargetHandle: false,
showSourceHandle: false,
+ hideScore: true,
onClick: (clickedMatch: Match) => {
if (
(placementMatch.state === MatchState.FINISHED
@@ -244,12 +246,7 @@ export default function GraphView({
panOnDrag={true}
proOptions={{ hideAttribution: true }}
>
-
+
);
diff --git a/frontend/app/events/[id]/bracket/page.tsx b/frontend/app/events/[id]/bracket/page.tsx
index 72225661..54792aad 100644
--- a/frontend/app/events/[id]/bracket/page.tsx
+++ b/frontend/app/events/[id]/bracket/page.tsx
@@ -1,12 +1,12 @@
-import type { Match } from "@/app/actions/tournament-model";
import { isActionError } from "@/app/actions/errors";
import { isEventAdmin } from "@/app/actions/event";
+import { getTeamsForEventTable } from "@/app/actions/team";
import {
getTournamentMatches,
getTournamentTeamCount,
} from "@/app/actions/tournament";
import Actions from "@/app/events/[id]/bracket/actions";
-import GraphView from "@/app/events/[id]/bracket/graphView";
+import BracketTabs from "@/app/events/[id]/bracket/BracketTabs";
export const metadata = {
title: "Tournament Bracket",
@@ -26,14 +26,14 @@ export default async function page({
throw new Error("Failed to verify admin status");
}
const isAdminView = (await searchParams).adminReveal === "true";
- const serializedMatches: Match[] = await getTournamentMatches(
- eventId,
- isAdminView,
- );
- const teamCount = await getTournamentTeamCount(eventId);
+ const [serializedMatches, teamCount, teams] = await Promise.all([
+ getTournamentMatches(eventId, isAdminView),
+ getTournamentTeamCount(eventId),
+ getTeamsForEventTable(eventId, undefined, "score", "desc", isAdminView),
+ ]);
return (
-
+
@@ -51,13 +51,13 @@ export default async function page({
)}
-
-
-
+
);
}
diff --git a/frontend/app/events/[id]/dashboard/dashboard.tsx b/frontend/app/events/[id]/dashboard/dashboard.tsx
index 6f4d3db8..8c81177c 100644
--- a/frontend/app/events/[id]/dashboard/dashboard.tsx
+++ b/frontend/app/events/[id]/dashboard/dashboard.tsx
@@ -17,8 +17,7 @@ import {
import { useSession } from "next-auth/react";
import Image from "next/image";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import React, { useEffect, useState } from "react";
+import { useEffect, useState } from "react";
import { toast } from "sonner";
import { isActionError } from "@/app/actions/errors";
import {
@@ -68,6 +67,7 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
+import { useTabParam } from "@/hooks/useTabParam";
import { cn } from "@/lib/utils";
import { StarterTemplatesManagement } from "./components/StarterTemplatesManagement";
@@ -78,16 +78,7 @@ interface DashboardPageProps {
export function DashboardPage({ eventId }: DashboardPageProps) {
const session = useSession();
const queryClient = useQueryClient();
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const currentTab = searchParams.get("tab") || "overview";
-
- const handleTabChange = (value: string) => {
- const params = new URLSearchParams(searchParams.toString());
- params.set("tab", value);
- router.push(`${pathname}?${params.toString()}`, { scroll: false });
- };
+ const { currentTab, onTabChange: handleTabChange } = useTabParam("overview");
const [teamAutoLockTime, setTeamAutoLockTime] = useState
("");
const [userSearchQuery, setUserSearchQuery] = useState("");
diff --git a/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx b/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx
index 74494d98..6ff1fa77 100644
--- a/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx
+++ b/frontend/app/events/[id]/groups/GroupPhaseTabs.tsx
@@ -3,8 +3,8 @@
import type { Team } from "@/app/actions/team";
import type { Match } from "@/app/actions/tournament-model";
import { BarChart3, Network } from "lucide-react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useTabParam } from "@/hooks/useTabParam";
import GraphView from "./graphView";
import RankingTable from "./RankingTable";
@@ -23,17 +23,7 @@ export default function GroupPhaseTabs({
isEventAdmin,
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 });
- };
+ const { currentTab, onTabChange } = useTabParam("graph");
return (
@@ -57,7 +47,7 @@ export default function GroupPhaseTabs({
-
+
{
if (b.score !== a.score)
@@ -43,8 +45,11 @@ export default function RankingTable({
.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 { id: m.id!, result: "T" };
+ return {
+ id: m.id!,
+ result: m.winner.id === teamId ? "W" : "L",
+ };
});
};
@@ -52,7 +57,7 @@ export default function RankingTable({
-
+
Rank
Participant
Score
@@ -69,24 +74,17 @@ export default function RankingTable({
return (
-
-
- {rank}
-
-
-
-
- {team.name}
-
-
-
+
+ router.push(`/events/${eventId}/teams/${team.id}`)}
+ >
+ {rank}
+ {team.name}
{(team.score ?? 0).toFixed(1)}
-
+
{(team.buchholzPoints ?? 0).toFixed(1)}
@@ -96,33 +94,13 @@ export default function RankingTable({
- {history.map((result, i) => (
-
- {result}
-
- ))}
- {history.length === 0 && (
-
- No matches
-
- )}
+
{isAtCutoff && index < sortedTeams.length - 1 && (
-
-
+
+
diff --git a/frontend/app/events/[id]/teams/TeamsTable.tsx b/frontend/app/events/[id]/teams/TeamsTable.tsx
index 1756510a..2bba8eec 100644
--- a/frontend/app/events/[id]/teams/TeamsTable.tsx
+++ b/frontend/app/events/[id]/teams/TeamsTable.tsx
@@ -114,7 +114,7 @@ export default function TeamsTable({ teams, eventId }: TeamsTableProps) {
table.getRowModel().rows.map(row => (
router.push(`/events/${eventId}/teams/${row.original.id}`)}
>
diff --git a/frontend/components/match/MatchHistoryBadges.tsx b/frontend/components/match/MatchHistoryBadges.tsx
new file mode 100644
index 00000000..6b9e8ec5
--- /dev/null
+++ b/frontend/components/match/MatchHistoryBadges.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+interface MatchResult {
+ id: string;
+ result: "W" | "L" | "T" | string;
+}
+
+interface MatchHistoryBadgesProps {
+ history: MatchResult[];
+ eventId: string;
+}
+
+export function MatchHistoryBadges({
+ history,
+ eventId,
+}: MatchHistoryBadgesProps) {
+ const router = useRouter();
+
+ if (history.length === 0) {
+ return No matches;
+ }
+
+ return (
+ <>
+ {history.map((match, i) => (
+ {
+ e.stopPropagation();
+ router.push(`/events/${eventId}/match/${match.id}`);
+ }}
+ className={cn(
+ "w-6 h-6 rounded flex items-center justify-center text-[11px] shadow-sm cursor-pointer hover:opacity-80 transition-opacity",
+ match.result === "W" && "bg-emerald-500 text-white",
+ match.result === "L" && "bg-destructive text-white",
+ match.result === "T" && "bg-muted-foreground text-white",
+ )}
+ >
+ {match.result}
+
+ ))}
+ >
+ );
+}
diff --git a/frontend/components/match/MatchNode.tsx b/frontend/components/match/MatchNode.tsx
index 97f5531b..4085db8d 100644
--- a/frontend/components/match/MatchNode.tsx
+++ b/frontend/components/match/MatchNode.tsx
@@ -14,6 +14,7 @@ interface MatchNodeData {
onClick?: (match: Match) => void;
showTargetHandle?: boolean;
showSourceHandle?: boolean;
+ hideScore?: boolean;
}
interface MatchNodeProps {
@@ -70,6 +71,7 @@ function MatchNode({ data }: MatchNodeProps) {
onClick,
showTargetHandle = false,
showSourceHandle = false,
+ hideScore = false,
} = data;
const styles = getMatchStateStyles(match.state);
const icon = getMatchStateIcon(match.state);
@@ -178,7 +180,8 @@ function MatchNode({ data }: MatchNodeProps) {
👑
)}
- {match.state === MatchState.FINISHED
+ {!hideScore
+ && match.state === MatchState.FINISHED
&& team.score !== undefined && (
{(match.results || []).find(
diff --git a/frontend/hooks/useTabParam.ts b/frontend/hooks/useTabParam.ts
new file mode 100644
index 00000000..229a4784
--- /dev/null
+++ b/frontend/hooks/useTabParam.ts
@@ -0,0 +1,17 @@
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+
+export function useTabParam(defaultTab: string = "graph") {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const currentTab = searchParams.get("tab") || defaultTab;
+
+ const onTabChange = (value: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("tab", value);
+ router.push(`${pathname}?${params.toString()}`, { scroll: false });
+ };
+
+ return { currentTab, onTabChange };
+}