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 }; +}