From d3a825bd5747f057c5fd5cdef21bf3610ec66fe8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 15 Dec 2024 12:24:11 +0200 Subject: [PATCH] Override tournament bracket destination (#1985) * Initial * Progress * Done? * Update seeding nth --- app/components/Button.tsx | 1 + app/components/icons/Edit.tsx | 7 +- app/db/tables.ts | 8 + .../components/Bracket/PlacementsTable.tsx | 352 + .../components/Bracket/RoundRobin.tsx | 154 +- .../components/Bracket/Swiss.tsx | 168 +- .../core/Progression.test.ts | 23 + .../tournament-bracket/core/Progression.ts | 19 + .../core/Tournament.test.ts | 179 +- .../tournament-bracket/core/Tournament.ts | 66 +- .../core/tests/mocks-sos.ts | 7630 +++++++++++++++++ .../tournament-bracket/core/tests/mocks.ts | 3 + .../core/tests/test-utils.ts | 1 + .../routes/to.$id.brackets.tsx | 32 + .../tournament-bracket-schemas.server.ts | 6 + .../tournament/TournamentRepository.server.ts | 37 + db-test.sqlite3 | Bin 872448 -> 884736 bytes migrations/076-progression-override.js | 21 + 18 files changed, 8378 insertions(+), 329 deletions(-) create mode 100644 app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx create mode 100644 app/features/tournament-bracket/core/tests/mocks-sos.ts create mode 100644 migrations/076-progression-override.js diff --git a/app/components/Button.tsx b/app/components/Button.tsx index d63b7361da..16bf8cf4f0 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -57,6 +57,7 @@ export function Button(props: ButtonProps) { {icon && React.cloneElement(icon, { className: clsx("button-icon", { lonely: !children }), + title: rest.title, })} {loading && loadingText ? loadingText : children} diff --git a/app/components/icons/Edit.tsx b/app/components/icons/Edit.tsx index 88165aecaa..92a9d2d1a1 100644 --- a/app/components/icons/Edit.tsx +++ b/app/components/icons/Edit.tsx @@ -1,4 +1,7 @@ -export function EditIcon({ className }: { className?: string }) { +export function EditIcon({ + className, + title, +}: { className?: string; title?: string }) { return ( - Edit Icon + {title ?? "Edit Icon"} ; } +export interface TournamentBracketProgressionOverride { + sourceBracketIdx: number; + destinationBracketIdx: number; + tournamentTeamId: number; + tournamentId: number; +} + export interface TrustRelationship { trustGiverUserId: number; trustReceiverUserId: number; @@ -923,6 +930,7 @@ export interface DB { TournamentOrganizationMember: TournamentOrganizationMember; TournamentOrganizationBadge: TournamentOrganizationBadge; TournamentOrganizationSeries: TournamentOrganizationSeries; + TournamentBracketProgressionOverride: TournamentBracketProgressionOverride; TrustRelationship: TrustRelationship; UnvalidatedUserSubmittedImage: UnvalidatedUserSubmittedImage; UnvalidatedVideo: UnvalidatedVideo; diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx new file mode 100644 index 0000000000..27993c3312 --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -0,0 +1,352 @@ +import { Link, useFetcher } from "@remix-run/react"; +import clsx from "clsx"; +import * as React from "react"; +import { Button } from "../../../../components/Button"; +import { CheckmarkIcon } from "../../../../components/icons/Checkmark"; +import { CrossIcon } from "../../../../components/icons/Cross"; +import { EditIcon } from "../../../../components/icons/Edit"; +import { logger } from "../../../../utils/logger"; +import { tournamentTeamPage } from "../../../../utils/urls"; +import { useUser } from "../../../auth/core/user"; +import type { Bracket } from "../../core/Bracket"; +import * as Progression from "../../core/Progression"; + +export function PlacementsTable({ + groupId, + bracket, + allMatchesFinished, +}: { + groupId: number; + bracket: Bracket; + allMatchesFinished: boolean; +}) { + const user = useUser(); + + const _standings = bracket + .currentStandings(true) + .filter((s) => s.groupId === groupId); + + const missingTeams = bracket.data.match.reduce((acc, cur) => { + if (cur.group_id !== groupId) return acc; + + if ( + cur.opponent1?.id && + !_standings.some((s) => s.team.id === cur.opponent1!.id) && + !acc.includes(cur.opponent1.id) + ) { + acc.push(cur.opponent1.id); + } + + if ( + cur.opponent2?.id && + !_standings.some((s) => s.team.id === cur.opponent2!.id) && + !acc.includes(cur.opponent2.id) + ) { + acc.push(cur.opponent2.id); + } + + return acc; + }, [] as number[]); + + const standings = _standings + .concat( + missingTeams.map((id) => ({ + team: bracket.tournament.teamById(id)!, + stats: { + mapLosses: 0, + mapWins: 0, + points: 0, + setLosses: 0, + setWins: 0, + winsAgainstTied: 0, + lossesAgainstTied: 0, + }, + placement: Math.max(..._standings.map((s) => s.placement)) + 1, + groupId, + })), + ) + .sort((a, b) => { + if (a.placement === b.placement && a.team.seed && b.team.seed) { + return a.team.seed - b.team.seed; + } + + return a.placement - b.placement; + }); + + const destinationBracket = (placement: number) => + bracket.tournament.brackets.find( + (b) => + b.id !== bracket.id && + b.sources?.some( + (s) => s.bracketIdx === 0 && s.placements.includes(placement), + ), + ); + + const possibleDestinationBrackets = Progression.destinationsFromBracketIdx( + bracket.idx, + bracket.tournament.ctx.settings.bracketProgression, + ).map((idx) => bracket.tournament.bracketByIdx(idx)!); + const canEditDestination = (() => { + const allDestinationsPreview = possibleDestinationBrackets.every( + (b) => b.preview, + ); + + return ( + bracket.tournament.isOrganizer(user) && + allDestinationsPreview && + allMatchesFinished + ); + })(); + + return ( + + + + + + {bracket.type === "round_robin" ? ( + + ) : null} + {bracket.type === "swiss" ? ( + + ) : null} + + {bracket.type === "round_robin" ? ( + + ) : null} + {bracket.type === "swiss" ? ( + <> + + + + ) : null} + + + + + {standings.map((s, i) => { + const stats = s.stats!; + if (!stats) { + logger.error("No stats for team", s.team); + return null; + } + + const team = bracket.tournament.teamById(s.team.id); + + const dest = destinationBracket(i + 1); + + const overridenDestination = + bracket.tournament.ctx.bracketProgressionOverrides.find( + (override) => + override.sourceBracketIdx === bracket.idx && + override.tournamentTeamId === s.team.id, + ); + const overridenDestinationBracket = overridenDestination + ? bracket.tournament.bracketByIdx( + overridenDestination.destinationBracketIdx, + )! + : undefined; + + return ( + + + + {bracket.type === "round_robin" ? ( + + ) : null} + {bracket.type === "swiss" ? ( + + ) : null} + + {bracket.type === "round_robin" ? ( + + ) : null} + {bracket.type === "swiss" ? ( + <> + + + + ) : null} + + + + ); + })} + +
Team + W/L + + TB + + TB + + W/L (M) + + Scr + + + Buch. + + + + Buch. (M) + + Seed + {canEditDestination ? : null} +
+ + {s.team.name}{" "} + + {s.team.droppedOut ? ( + + Drop-out + + ) : null} + + + {stats.setWins}/{stats.setLosses} + + + {stats.winsAgainstTied} + + {(stats.lossesAgainstTied ?? 0) * -1} + + + {stats.mapWins}/{stats.mapLosses} + + + {stats.points} + + {stats.buchholzSets} + + {stats.buchholzMaps} + {team?.seed}
+ ); +} + +function EditableDestination({ + source, + destination, + overridenDestination, + possibleDestinations: _possibleDestinations, + allMatchesFinished, + canEditDestination, + tournamentTeamId, +}: { + source: Bracket; + destination?: Bracket; + overridenDestination?: Bracket; + possibleDestinations: Bracket[]; + allMatchesFinished: boolean; + canEditDestination: boolean; + tournamentTeamId: number; +}) { + const fetcher = useFetcher(); + const [editingDestination, setEditingDestination] = React.useState(false); + const [newDestinationIdx, setNewDestinationIdx] = React.useState< + number | null + >(overridenDestination?.idx ?? destination?.idx ?? -1); + + const handleSubmit = () => { + fetcher.submit( + { + _action: "OVERRIDE_BRACKET_PROGRESSION", + tournamentTeamId, + sourceBracketIdx: source.idx, + destinationBracketIdx: newDestinationIdx, + }, + { method: "post", encType: "application/json" }, + ); + }; + + const possibleDestinations = !destination + ? (["ELIMINATED", ..._possibleDestinations] as const) + : _possibleDestinations; + + if (editingDestination) { + return ( + <> + + + + +
+
+ + + ); + } + + return ( + <> + {allMatchesFinished && + overridenDestination && + overridenDestination.idx !== destination?.idx ? ( + + → {overridenDestination.name} + + ) : destination ? ( + + → {destination.name} + + ) : ( + + )} + {canEditDestination ? ( + +