sendou.ink/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx
Kalle d3a825bd57
Some checks failed
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Override tournament bracket destination (#1985)
* Initial

* Progress

* Done?

* Update seeding nth
2024-12-15 12:24:11 +02:00

353 lines
8.5 KiB
TypeScript

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 (
<table className="rr__placements-table" cellSpacing={0}>
<thead>
<tr>
<th>Team</th>
<th>
<abbr title="Set wins and losses">W/L</abbr>
</th>
{bracket.type === "round_robin" ? (
<th>
<abbr title="Wins against tied opponents">TB</abbr>
</th>
) : null}
{bracket.type === "swiss" ? (
<th>
<abbr title="Losses against tied opponents">TB</abbr>
</th>
) : null}
<th>
<abbr title="Map wins and losses">W/L (M)</abbr>
</th>
{bracket.type === "round_robin" ? (
<th>
<abbr title="Score summed up">Scr</abbr>
</th>
) : null}
{bracket.type === "swiss" ? (
<>
<th>
<abbr title="Buchholz (summed set wins of opponents)">
Buch.
</abbr>
</th>
<th>
<abbr title="Buchholz (summed map wins of opponents)">
Buch. (M)
</abbr>
</th>
</>
) : null}
<th>Seed</th>
<th />
{canEditDestination ? <th /> : null}
</tr>
</thead>
<tbody>
{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 (
<tr key={s.team.id}>
<td>
<Link
to={tournamentTeamPage({
tournamentId: bracket.tournament.ctx.id,
tournamentTeamId: s.team.id,
})}
>
{s.team.name}{" "}
</Link>
{s.team.droppedOut ? (
<span className="text-warning text-xxxs font-bold">
Drop-out
</span>
) : null}
</td>
<td>
<span>
{stats.setWins}/{stats.setLosses}
</span>
</td>
{bracket.type === "round_robin" ? (
<td>
<span>{stats.winsAgainstTied}</span>
</td>
) : null}
{bracket.type === "swiss" ? (
<td>
<span>{(stats.lossesAgainstTied ?? 0) * -1}</span>
</td>
) : null}
<td>
<span>
{stats.mapWins}/{stats.mapLosses}
</span>
</td>
{bracket.type === "round_robin" ? (
<td>
<span>{stats.points}</span>
</td>
) : null}
{bracket.type === "swiss" ? (
<>
<td>
<span>{stats.buchholzSets}</span>
</td>
<td>
<span>{stats.buchholzMaps}</span>
</td>
</>
) : null}
<td>{team?.seed}</td>
<EditableDestination
key={overridenDestinationBracket?.idx}
source={bracket}
destination={dest}
overridenDestination={overridenDestinationBracket}
possibleDestinations={possibleDestinationBrackets}
allMatchesFinished={allMatchesFinished}
canEditDestination={canEditDestination}
tournamentTeamId={s.team.id}
/>
</tr>
);
})}
</tbody>
</table>
);
}
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<any>();
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 (
<>
<td>
<select
value={String(newDestinationIdx)}
onChange={(e) => setNewDestinationIdx(Number(e.target.value))}
>
{possibleDestinations.map((b) => (
<option
key={b === "ELIMINATED" ? "ELIMINATED" : b.id}
value={b === "ELIMINATED" ? -1 : b.idx}
>
{b === "ELIMINATED" ? "Eliminated" : b.name}
</option>
))}
</select>
</td>
<td>
<div className="stack horizontal xs">
<Button
variant="minimal"
title="Save destination"
icon={<CheckmarkIcon />}
size="tiny"
onClick={handleSubmit}
/>
<Button
variant="minimal-destructive"
title="Cancel"
size="tiny"
icon={<CrossIcon />}
onClick={() => setEditingDestination(false)}
/>
</div>
</td>
</>
);
}
return (
<>
{allMatchesFinished &&
overridenDestination &&
overridenDestination.idx !== destination?.idx ? (
<td className="text-theme font-bold">
<span> {overridenDestination.name}</span>
</td>
) : destination ? (
<td
className={clsx({
"italic text-lighter": !allMatchesFinished,
})}
>
<span> {destination.name}</span>
</td>
) : (
<td />
)}
{canEditDestination ? (
<td>
<Button
variant="minimal"
title="Edit destination"
icon={<EditIcon />}
size="tiny"
onClick={() => setEditingDestination(true)}
/>
</td>
) : null}
</>
);
}