diff --git a/app/components/Divider.tsx b/app/components/Divider.tsx index 32b6ea7d5..f6c547855 100644 --- a/app/components/Divider.tsx +++ b/app/components/Divider.tsx @@ -1,3 +1,11 @@ -export function Divider({ children }: { children: React.ReactNode }) { - return
{children}
; +import clsx from "clsx"; + +export function Divider({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; } diff --git a/app/components/Placement.tsx b/app/components/Placement.tsx index 54ec9d0a2..e3df271c6 100644 --- a/app/components/Placement.tsx +++ b/app/components/Placement.tsx @@ -9,6 +9,7 @@ export type PlacementProps = { placement: number; iconClassName?: string; textClassName?: string; + size?: number; }; const getSpecialPlacementIconPath = (placement: number): string | null => { @@ -28,6 +29,7 @@ export function Placement({ placement, iconClassName, textClassName, + size = 20, }: PlacementProps) { const { t } = useTranslation(undefined, {}); @@ -62,8 +64,8 @@ export function Placement({ title={placementString} src={iconPath} className={iconClassName} - height={20} - width={20} + height={size} + width={size} /> ); } diff --git a/app/features/tournament-bracket/core/finalStandings.server.ts b/app/features/tournament-bracket/core/finalStandings.server.ts new file mode 100644 index 000000000..ed8d07087 --- /dev/null +++ b/app/features/tournament-bracket/core/finalStandings.server.ts @@ -0,0 +1,58 @@ +import type { Tournament, TournamentTeam } from "~/db/types"; +import type { BracketsManager } from "~/modules/brackets-manager"; +import type { FinalStandingsItem } from "~/modules/brackets-manager/types"; +import type { PlayerThatPlayedByTeamId } from "../queries/playersThatPlayedByTeamId.server"; +import { playersThatPlayedByTeamId } from "../queries/playersThatPlayedByTeamId.server"; + +export interface FinalStanding { + tournamentTeam: Pick; + placement: number; // 1st, 2nd, 3rd, 4th, 5th, 5th... + players: PlayerThatPlayedByTeamId[]; +} + +const STANDINGS_TO_INCLUDE = 8; + +export function finalStandings({ + manager, + tournamentId, +}: { + manager: BracketsManager; + tournamentId: Tournament["id"]; +}): Array | null { + let standings: FinalStandingsItem[]; + try { + standings = manager.get.finalStandings(tournamentId); + } catch (e) { + if (!(e instanceof Error)) throw e; + + if (e.message.includes("The final match does not have a winner")) { + console.error(e); + return null; + } + + throw e; + } + + const result: Array = []; + + let lastRank = 1; + let currentPlacement = 1; + for (const [i, standing] of standings + .slice(0, STANDINGS_TO_INCLUDE) + .entries()) { + if (lastRank !== standing.rank) { + lastRank = standing.rank; + currentPlacement = i + 1; + } + result.push({ + tournamentTeam: { + id: standing.id, + name: standing.name, + }, + placement: currentPlacement, + players: playersThatPlayedByTeamId(standing.id), + }); + } + + return result; +} diff --git a/app/features/tournament-bracket/queries/playersThatPlayedByTeamId.server.ts b/app/features/tournament-bracket/queries/playersThatPlayedByTeamId.server.ts new file mode 100644 index 000000000..6090f70f2 --- /dev/null +++ b/app/features/tournament-bracket/queries/playersThatPlayedByTeamId.server.ts @@ -0,0 +1,35 @@ +import { sql } from "~/db/sql"; +import type { User } from "~/db/types"; + +const stm = sql.prepare(/* sql */ ` + select + "User"."id", + "User"."discordName", + "User"."discordAvatar", + "User"."discordId", + "User"."customUrl" + from "TournamentTeam" + left join "TournamentTeamMember" on "TournamentTeamMember"."tournamentTeamId" = "TournamentTeam"."id" + left join "User" on "User"."id" = "TournamentTeamMember"."userId" + left join "TournamentStage" on "TournamentStage"."tournamentId" = "TournamentTeam"."tournamentId" + left join "TournamentMatch" on "TournamentMatch"."stageId" = "TournamentStage"."id" + left join "TournamentMatchGameResult" on "TournamentMatchGameResult"."matchId" = "TournamentMatch"."id" + right join "TournamentMatchGameResultParticipant" on + "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id" + and + "TournamentTeamMember"."userId" = "TournamentMatchGameResultParticipant"."userId" + + where "TournamentTeam"."id" = @tournamentTeamId + group by "User"."id" +`); + +export type PlayerThatPlayedByTeamId = Pick< + User, + "id" | "discordName" | "discordAvatar" | "discordId" | "customUrl" +>; + +export function playersThatPlayedByTeamId( + tournamentTeamId: number +): PlayerThatPlayedByTeamId[] { + return stm.all({ tournamentTeamId }); +} diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 013fee504..1893ac059 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -2,9 +2,11 @@ import type { ActionFunction, LinksFunction, LoaderArgs, + SerializeFrom, } from "@remix-run/node"; import { Form, + Link, useLoaderData, useNavigate, useOutletContext, @@ -23,6 +25,7 @@ import { notFoundIfFalsy, validate } from "~/utils/remix"; import { tournamentBracketsSubscribePage, tournamentMatchPage, + userPage, } from "~/utils/urls"; import type { TournamentLoaderData } from "../../tournament/routes/to.$id"; import { resolveBestOfs } from "../core/bestOf.server"; @@ -33,6 +36,7 @@ import { requireUser, useUser } from "~/modules/auth"; import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament"; import { bracketSubscriptionKey, + everyMatchIsOver, fillWithNullTillPowerOfTwo, resolveTournamentStageName, resolveTournamentStageSettings, @@ -43,9 +47,15 @@ import { useEventSource } from "remix-utils"; import { Status } from "~/db/types"; import { checkInHasStarted, teamHasCheckedIn } from "~/features/tournament"; import clsx from "clsx"; -import { LinkButton } from "~/components/Button"; +import { Button, LinkButton } from "~/components/Button"; import { useVisibilityChange } from "~/hooks/useVisibilityChange"; import { bestOfsByTournamentId } from "../queries/bestOfsByTournamentId.server"; +import type { FinalStanding } from "../core/finalStandings.server"; +import { finalStandings } from "../core/finalStandings.server"; +import { Placement } from "~/components/Placement"; +import { Avatar } from "~/components/Avatar"; +import { Divider } from "~/components/Divider"; +import { removeDuplicates } from "~/utils/arrays"; export const links: LinksFunction = () => { return [ @@ -100,6 +110,8 @@ export const action: ActionFunction = async ({ params, request }) => { return null; }; +export type TournamentBracketLoaderData = SerializeFrom; + export const loader = ({ params }: LoaderArgs) => { const tournamentId = tournamentIdFromParams(params); @@ -107,11 +119,17 @@ export const loader = ({ params }: LoaderArgs) => { const manager = getTournamentManager(hasStarted ? "SQL" : "IN_MEMORY"); if (hasStarted) { + const bracket = manager.get.tournamentData(tournamentId); + const _everyMatchIsOver = everyMatchIsOver(bracket); return { hasStarted: true, enoughTeams: true, - bracket: manager.get.tournamentData(tournamentId), + bracket, roundBestOfs: bestOfsByTournamentId(tournamentId), + everyMatchIsOver: _everyMatchIsOver, + finalStandings: _everyMatchIsOver + ? finalStandings({ manager, tournamentId }) + : null, }; } @@ -140,7 +158,9 @@ export const loader = ({ params }: LoaderArgs) => { bracket: data, hasStarted, enoughTeams, + everyMatchIsOver: false, roundBestOfs: null, + finalStandings: null, }; }; @@ -275,6 +295,9 @@ export default function TournamentBracketsPage() { {parentRouteData.hasStarted && myTeam ? ( ) : null} + {data.finalStandings ? ( + + ) : null}
{!data.enoughTeams ? (
@@ -422,6 +445,127 @@ function TournamentProgressPrompt({ ownedTeamId }: { ownedTeamId: number }) { ); } +function FinalStandings({ standings }: { standings: FinalStanding[] }) { + const [viewAll, setViewAll] = React.useState(false); + + if (standings.length < 3) { + console.error("Unexpectedly few standings"); + return null; + } + + const [first, second, third, ...rest] = standings; + + const nonTopThreePlacements = viewAll + ? removeDuplicates(rest.map((s) => s.placement)) + : []; + + return ( +
+ {[third, first, second].map((standing) => { + return ( +
+
+ +
+
+ {standing.tournamentTeam.name} +
+
+ {standing.players.map((player) => { + return ( + + + + ); + })} +
+
+ {standing.players.map((player) => { + return ( + + {player.discordName} + + ); + })} +
+
+ ); + })} + {nonTopThreePlacements.map((placement) => { + return ( + + + + +
+ {standings + .filter((s) => s.placement === placement) + .map((standing) => { + return ( +
+
+ {standing.tournamentTeam.name} +
+
+ {standing.players.map((player) => { + return ( + + + + ); + })} +
+
+ {standing.players.map((player) => { + return ( + + {player.discordName} + + ); + })} +
+
+ ); + })} +
+
+ ); + })} +
+ +
+ ); +} + function TournamentProgressContainer({ children, }: { diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index e2cd89176..c2ae3d85d 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -16,6 +16,7 @@ import type { } from "~/features/tournament"; import type { Params } from "@remix-run/react"; import invariant from "tiny-invariant"; +import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types"; export function matchIdFromParams(params: Params) { const result = Number(params["mid"]); @@ -144,3 +145,31 @@ export function fillWithNullTillPowerOfTwo(arr: T[]) { return [...arr, ...new Array(nullsToAdd).fill(null)]; } + +export function everyMatchIsOver(bracket: ValueToArray) { + let lastWinner = -1; + for (const [i, match] of bracket.match.entries()) { + // special case - bracket reset might not be played depending on who wins in the grands + if ( + match.group_id === 3 && + i === bracket.match.length - 1 && + lastWinner === 1 + ) { + continue; + } + // BYE + if (match.opponent1 === null || match.opponent2 === null) { + continue; + } + if ( + match.opponent1?.result !== "win" && + match.opponent2?.result !== "win" + ) { + return false; + } + + lastWinner = match.opponent1?.result === "win" ? 1 : 2; + } + + return true; +} diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css index 2cc31cfe7..71a63a3bf 100644 --- a/app/features/tournament-bracket/tournament-bracket.css +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -251,6 +251,42 @@ gap: var(--s-3); } +.tournament-bracket__standings { + background-color: var(--bg-lighter-transparent); + display: grid; + grid-template-columns: 1fr 1fr 1fr; + border-radius: var(--rounded); + padding: var(--s-3); + gap: var(--s-2); + max-width: max-content; + margin: 0 auto; +} +.tournament-bracket__stadings__full-row-taker { + grid-column: 1 / 4; +} + +.tournament-bracket__standing { + display: flex; + align-items: center; + flex-direction: column; + gap: var(--s-2); +} + +.tournament-bracket__standing__team-name { + font-weight: var(--semi-bold); + text-align: center; + font-size: var(-fonts-sm); +} + +.tournament-bracket__standing__team-name__big { + font-size: var(--fonts-lg); +} + +.tournament-bracket__standings__show-more { + margin-block-start: var(--s-2); + margin-inline: auto; +} + @media screen and (min-width: 480px) { .tournament-bracket__infos { flex-direction: row; diff --git a/app/modules/brackets-manager/types.ts b/app/modules/brackets-manager/types.ts index 4f06b58d1..e8762dee0 100644 --- a/app/modules/brackets-manager/types.ts +++ b/app/modules/brackets-manager/types.ts @@ -84,7 +84,7 @@ export type DeepPartial = T extends object /** * Converts all value types to array types. */ -type ValueToArray = { +export type ValueToArray = { [K in keyof T]: Array; }; diff --git a/app/styles/common.css b/app/styles/common.css index 585f33b2a..337249e2e 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -759,6 +759,10 @@ dialog::backdrop { gap: var(--s-8); } +.stack.xl { + gap: var(--s-12); +} + .stack.horizontal { flex-direction: row; }