sendou.ink/app/features/tournament-bracket/components/Bracket/Swiss.tsx
2025-12-10 19:42:30 +02:00

289 lines
7.6 KiB
TypeScript

import { useFetcher } from "@remix-run/react";
import clsx from "clsx";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import {
useBracketExpanded,
useTournament,
} from "~/features/tournament/routes/to.$id";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { Match as MatchType } from "~/modules/brackets-model";
import type { Bracket as BracketType } from "../../core/Bracket";
import { groupNumberToLetters } from "../../tournament-bracket-utils";
import { Match } from "./Match";
import { PlacementsTable } from "./PlacementsTable";
import { RoundHeader } from "./RoundHeader";
export function SwissBracket({
bracket,
bracketIdx,
}: {
bracket: BracketType;
bracketIdx: number;
}) {
const user = useUser();
const tournament = useTournament();
const { bracketExpanded } = useBracketExpanded();
const groups = getGroups(bracket);
const [selectedGroupId, setSelectedGroupId] = useSearchParamState({
defaultValue: groups[0].groupId,
name: "group",
revive: (id) =>
groups.find((g) => g.groupId === Number(id))
? Number(id)
: groups[0].groupId,
});
const fetcher = useFetcher();
const selectedGroup = groups.find((g) => g.groupId === selectedGroupId)!;
const rounds = bracket.data.round.filter(
(r) => r.group_id === selectedGroupId,
);
// when bracket starts we go from "virtual id" to a real one
// which would cause the admin to see empty group after starting
// bracket
if (!groups.some((g) => g.groupId === selectedGroupId)) {
setSelectedGroupId(groups[0].groupId);
}
const someMatchOngoing = (matches: MatchType[]) =>
matches.some(
(match) =>
match.opponent1 &&
match.opponent2 &&
match.opponent1.result !== "win" &&
match.opponent2.result !== "win",
);
const allRoundsFinished = () => {
for (const round of rounds) {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id && match.group_id === selectedGroupId,
);
if (matches.length === 0 || someMatchOngoing(matches)) {
return false;
}
}
return true;
};
const roundThatCanBeStartedId = () => {
if (!tournament.isOrganizer(user) || bracket.preview) return undefined;
for (const round of rounds) {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id && match.group_id === selectedGroupId,
);
if (someMatchOngoing(matches) && matches.length > 0) {
return undefined;
}
if (matches.length === 0) {
return round.id;
}
}
return;
};
return (
<div className="stack xl">
<div className="stack lg">
{groups.length > 1 && (
<div className="stack horizontal">
{groups.map((g) => (
<SendouButton
key={g.groupId}
onPress={() => setSelectedGroupId(g.groupId)}
className={clsx(
"tournament-bracket__bracket-nav__link tournament-bracket__bracket-nav__link__big",
{
"tournament-bracket__bracket-nav__link__selected":
selectedGroupId === g.groupId,
},
)}
data-testid={`group-${g.groupName.split(" ")[1]}-button`}
>
{g.groupName.split(" ")[1]}
</SendouButton>
))}
</div>
)}
<div className="stack lg">
{rounds.map((round, roundI) => {
const matches = bracket.data.match.filter(
(match) =>
match.round_id === round.id &&
match.group_id === selectedGroupId,
);
if (
matches.length > 0 &&
!bracketExpanded &&
!someMatchOngoing(matches) &&
roundI !== rounds.length - 1
) {
return null;
}
const bestOf = round.maps?.count;
const ongoingMatches = matches.filter(
(m) =>
m.opponent1 &&
m.opponent2 &&
!m.opponent1.result &&
!m.opponent2.result,
);
const startedAtValues = ongoingMatches
.map((m) => m.startedAt)
.filter((t): t is number => typeof t === "number");
const roundStartedAt =
startedAtValues.length > 0 ? Math.min(...startedAtValues) : null;
const teamWithByeId = matches.find((m) => !m.opponent2)?.opponent1
?.id;
const teamWithBye = teamWithByeId
? tournament.teamById(teamWithByeId)
: null;
return (
<div
key={round.id}
className={matches.length > 0 ? "stack md-plus" : "stack"}
>
<div className="stack sm horizontal">
<RoundHeader
roundId={round.id}
name={`Round ${round.number}`}
bestOf={bestOf}
showInfos={someMatchOngoing(matches)}
maps={round.maps}
roundStartedAt={roundStartedAt}
matches={ongoingMatches}
/>
{roundThatCanBeStartedId() === round.id ? (
<fetcher.Form method="post">
<input
type="hidden"
name="groupId"
value={selectedGroupId}
/>
<input
type="hidden"
name="bracketIdx"
value={bracketIdx}
/>
<SubmitButton
_action="ADVANCE_BRACKET"
state={fetcher.state}
testId="start-round-button"
>
Start round
</SubmitButton>
</fetcher.Form>
) : null}
{someMatchOngoing(matches) &&
tournament.isOrganizer(user) &&
roundI > 0 ? (
<FormWithConfirm
dialogHeading={`Delete all matches of round ${round.number}?`}
fields={[
["groupId", selectedGroupId],
["roundId", round.id],
["bracketIdx", bracketIdx],
["_action", "UNADVANCE_BRACKET"],
]}
>
<SendouButton
variant="minimal-destructive"
type="submit"
className="small-text mb-4"
size="small"
data-testid="reset-round-button"
>
Reset round
</SendouButton>
</FormWithConfirm>
) : null}
</div>
<div className="stack horizontal md lg-row flex-wrap">
{matches.length === 0 ? (
<div className="text-lighter text-md font-bold">
Waiting for the previous round to finish
</div>
) : null}
{matches.map((match) => {
if (!match.opponent1 || !match.opponent2) {
return null;
}
return (
<Match
key={match.id}
match={match}
roundNumber={round.number}
isPreview={bracket.preview}
showSimulation={false}
bracket={bracket}
type="groups"
group={selectedGroup.groupName.split(" ")[1]}
hideMatchTimer
/>
);
})}
</div>
{teamWithBye ? (
<div
className="text-xs text-lighter font-semi-bold"
data-testid="bye-team"
>
BYE: {teamWithBye.name}
</div>
) : null}
</div>
);
})}
</div>
<PlacementsTable
bracket={bracket}
groupId={selectedGroupId}
allMatchesFinished={allRoundsFinished()}
/>
</div>
</div>
);
}
function getGroups(bracket: BracketType) {
const result: Array<{
groupName: string;
matches: MatchType[];
groupId: number;
}> = [];
for (const group of bracket.data.group) {
const matches = bracket.data.match.filter(
(match) => match.group_id === group.id,
);
result.push({
groupName: `Group ${groupNumberToLetters(group.number)}`,
matches,
groupId: group.id,
});
}
return result;
}