mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-02 22:26:57 -05:00
Final placements display
This commit is contained in:
parent
3a63c0adcc
commit
afbb0fd689
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -759,6 +759,10 @@ dialog::backdrop {
|
|||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.stack.xl {
|
||||
gap: var(--s-12);
|
||||
}
|
||||
|
||||
.stack.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user