Final placements display

This commit is contained in:
Kalle 2023-05-20 22:31:24 +03:00
parent 3a63c0adcc
commit afbb0fd689
9 changed files with 323 additions and 7 deletions

View File

@ -1,3 +1,11 @@
export function Divider({ children }: { children: React.ReactNode }) {
return <div className="divider">{children}</div>;
import clsx from "clsx";
export function Divider({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={clsx("divider", className)}>{children}</div>;
}

View File

@ -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}
/>
);
}

View File

@ -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<TournamentTeam, "id" | "name">;
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<FinalStanding> | 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<FinalStanding> = [];
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;
}

View File

@ -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 });
}

View File

@ -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<typeof loader>;
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 ? (
<TournamentProgressPrompt ownedTeamId={myTeam.id} />
) : null}
{data.finalStandings ? (
<FinalStandings standings={data.finalStandings} />
) : null}
<div className="brackets-viewer" ref={ref}></div>
{!data.enoughTeams ? (
<div className="text-center text-lg font-semi-bold text-lighter">
@ -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 (
<div className="tournament-bracket__standings">
{[third, first, second].map((standing) => {
return (
<div
className="tournament-bracket__standing"
key={standing.tournamentTeam.id}
>
<div>
<Placement placement={standing.placement} size={40} />
</div>
<div className="tournament-bracket__standing__team-name tournament-bracket__standing__team-name__big">
{standing.tournamentTeam.name}
</div>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
return (
<Link
to={userPage(player)}
key={player.id}
className="stack items-center text-xs"
>
<Avatar user={player} size="xxs" />
</Link>
);
})}
</div>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
return (
<Link
to={userPage(player)}
key={player.id}
className="stack items-center text-xs"
>
{player.discordName}
</Link>
);
})}
</div>
</div>
);
})}
{nonTopThreePlacements.map((placement) => {
return (
<React.Fragment key={placement}>
<Divider className="tournament-bracket__stadings__full-row-taker">
<Placement placement={placement} />
</Divider>
<div className="stack xl horizontal justify-center tournament-bracket__stadings__full-row-taker">
{standings
.filter((s) => s.placement === placement)
.map((standing) => {
return (
<div
className="tournament-bracket__standing"
key={standing.tournamentTeam.id}
>
<div className="tournament-bracket__standing__team-name">
{standing.tournamentTeam.name}
</div>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
return (
<Link
to={userPage(player)}
key={player.id}
className="stack items-center text-xs"
>
<Avatar user={player} size="xxs" />
</Link>
);
})}
</div>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.players.map((player) => {
return (
<Link
to={userPage(player)}
key={player.id}
className="stack items-center text-xs"
>
{player.discordName}
</Link>
);
})}
</div>
</div>
);
})}
</div>
</React.Fragment>
);
})}
<div />
<Button
variant="outlined"
className="tournament-bracket__standings__show-more"
size="tiny"
onClick={() => setViewAll((v) => !v)}
>
{viewAll ? "Show less" : "Show more"}
</Button>
</div>
);
}
function TournamentProgressContainer({
children,
}: {

View File

@ -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<string>) {
const result = Number(params["mid"]);
@ -144,3 +145,31 @@ export function fillWithNullTillPowerOfTwo<T>(arr: T[]) {
return [...arr, ...new Array(nullsToAdd).fill(null)];
}
export function everyMatchIsOver(bracket: ValueToArray<DataTypes>) {
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;
}

View File

@ -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;

View File

@ -84,7 +84,7 @@ export type DeepPartial<T> = T extends object
/**
* Converts all value types to array types.
*/
type ValueToArray<T> = {
export type ValueToArray<T> = {
[K in keyof T]: Array<T[K]>;
};

View File

@ -759,6 +759,10 @@ dialog::backdrop {
gap: var(--s-8);
}
.stack.xl {
gap: var(--s-12);
}
.stack.horizontal {
flex-direction: row;
}