mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-05 20:56:13 -05:00
471 lines
12 KiB
TypeScript
471 lines
12 KiB
TypeScript
import { Link, useFetcher } from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import invariant from "~/utils/invariant";
|
|
import { SendouButton } from "../../../../components/elements/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 { TOURNAMENT } from "../../../tournament/tournament-constants";
|
|
import type { Bracket } from "../../core/Bracket";
|
|
import * as Progression from "../../core/Progression";
|
|
import * as Swiss from "../../core/Swiss";
|
|
import styles from "./bracket.module.css";
|
|
|
|
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) => {
|
|
if (bracket.type === "swiss" && bracket.settings?.advanceThreshold) {
|
|
const standing = standings[placement - 1];
|
|
const stats = standing.stats;
|
|
invariant(stats);
|
|
|
|
return Swiss.calculateTeamStatus({
|
|
advanceThreshold: bracket.settings.advanceThreshold,
|
|
losses: stats.setLosses,
|
|
wins: stats.setWins,
|
|
roundCount:
|
|
bracket.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
|
|
}) === "advanced"
|
|
? bracket.tournament.brackets.find((otherBracket) =>
|
|
otherBracket.sources?.some(
|
|
(source) => source.bracketIdx === bracket.idx,
|
|
),
|
|
)
|
|
: undefined;
|
|
}
|
|
|
|
return bracket.tournament.brackets.find(
|
|
(b) =>
|
|
b.idx ===
|
|
Progression.destinationByPlacement({
|
|
sourceBracketIdx: bracket.idx,
|
|
placement,
|
|
progression: bracket.tournament.ctx.settings.bracketProgression,
|
|
}),
|
|
);
|
|
};
|
|
|
|
const possibleDestinationBrackets = Progression.destinationsFromBracketIdx(
|
|
bracket.idx,
|
|
bracket.tournament.ctx.settings.bracketProgression,
|
|
).map((idx) => bracket.tournament.bracketByIdx(idx)!);
|
|
const canEditDestination = (() => {
|
|
if (possibleDestinationBrackets.length === 0) return false;
|
|
|
|
const allDestinationsPreview = possibleDestinationBrackets.every(
|
|
(b) => b.preview,
|
|
);
|
|
|
|
return (
|
|
bracket.tournament.isOrganizer(user) &&
|
|
allDestinationsPreview &&
|
|
allMatchesFinished
|
|
);
|
|
})();
|
|
|
|
let qualifiedRowRendered = false;
|
|
let eliminatedRowRendered = false;
|
|
|
|
return (
|
|
<table className={styles.rrPlacementsTable} 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}
|
|
{bracket.type === "swiss" ? (
|
|
<th>
|
|
<abbr title="Opponents' set win percentage average">OW%</abbr>
|
|
</th>
|
|
) : null}
|
|
<th>
|
|
<abbr title="Map wins and losses">W/L (M)</abbr>
|
|
</th>
|
|
{bracket.type === "swiss" ? (
|
|
<th>
|
|
<abbr title="Opponents' map win percentage average">OW% (M)</abbr>
|
|
</th>
|
|
) : null}
|
|
{bracket.type === "round_robin" ? (
|
|
<th>
|
|
<abbr title="Score summed up">Scr</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;
|
|
|
|
const key = () => {
|
|
if (overridenDestinationBracket === null) {
|
|
return `${s.team.id}-null`;
|
|
}
|
|
|
|
return `${s.team.id}-${overridenDestinationBracket?.idx}`;
|
|
};
|
|
|
|
const renderQualifiedRow =
|
|
!qualifiedRowRendered &&
|
|
bracket.settings?.advanceThreshold &&
|
|
s.stats &&
|
|
s.stats.setWins < bracket.settings.advanceThreshold;
|
|
const renderEliminatedRow =
|
|
!eliminatedRowRendered &&
|
|
bracket.settings?.advanceThreshold &&
|
|
s.stats &&
|
|
Swiss.calculateTeamStatus({
|
|
advanceThreshold: bracket.settings.advanceThreshold,
|
|
losses: s.stats.setLosses,
|
|
wins: s.stats.setWins,
|
|
roundCount:
|
|
bracket.settings.roundCount ??
|
|
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
|
|
}) === "eliminated";
|
|
|
|
if (renderQualifiedRow) qualifiedRowRendered = true;
|
|
if (renderEliminatedRow) eliminatedRowRendered = true;
|
|
|
|
return (
|
|
<React.Fragment key={s.team.id}>
|
|
{renderQualifiedRow ? (
|
|
<SwissDividerRow
|
|
key="qualified"
|
|
type="qualified"
|
|
threshold={bracket.settings!.advanceThreshold!}
|
|
// TODO: get columns from the tiebreakers array
|
|
columnCount={8 + (canEditDestination ? 1 : 0)}
|
|
/>
|
|
) : null}
|
|
{renderEliminatedRow ? (
|
|
<SwissDividerRow
|
|
key="eliminated"
|
|
type="eliminated"
|
|
threshold={bracket.settings!.advanceThreshold!}
|
|
// TODO: get columns from the tiebreakers array
|
|
columnCount={8 + (canEditDestination ? 1 : 0)}
|
|
/>
|
|
) : null}
|
|
<tr>
|
|
<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}
|
|
{bracket.type === "swiss" ? (
|
|
<td>
|
|
<span>{stats.opponentSetWinPercentage?.toFixed(2)}</span>
|
|
</td>
|
|
) : null}
|
|
<td>
|
|
<span>
|
|
{stats.mapWins}/{stats.mapLosses}
|
|
</span>
|
|
</td>
|
|
{bracket.type === "swiss" ? (
|
|
<td>
|
|
<span>{stats.opponentMapWinPercentage?.toFixed(2)}</span>
|
|
</td>
|
|
) : null}
|
|
{bracket.type === "round_robin" ? (
|
|
<td>
|
|
<span>{stats.points}</span>
|
|
</td>
|
|
) : null}
|
|
<td>{team?.seed}</td>
|
|
<EditableDestination
|
|
key={key()}
|
|
source={bracket}
|
|
destination={dest}
|
|
overridenDestination={overridenDestinationBracket}
|
|
possibleDestinations={possibleDestinationBrackets}
|
|
allMatchesFinished={allMatchesFinished}
|
|
canEditDestination={canEditDestination}
|
|
tournamentTeamId={s.team.id}
|
|
/>
|
|
</tr>
|
|
{!eliminatedRowRendered &&
|
|
i === standings.length - 1 &&
|
|
bracket.settings?.advanceThreshold ? (
|
|
<SwissDividerRow
|
|
key="eliminated"
|
|
type="eliminated"
|
|
threshold={bracket.settings.advanceThreshold}
|
|
// TODO: get columns from the tiebreakers array
|
|
columnCount={8 + (canEditDestination ? 1 : 0)}
|
|
/>
|
|
) : null}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
function EditableDestination({
|
|
source,
|
|
destination,
|
|
overridenDestination,
|
|
possibleDestinations: _possibleDestinations,
|
|
allMatchesFinished,
|
|
canEditDestination,
|
|
tournamentTeamId,
|
|
}: {
|
|
source: Bracket;
|
|
destination?: Bracket;
|
|
overridenDestination?: Bracket | null;
|
|
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 = [
|
|
"ELIMINATED",
|
|
..._possibleDestinations,
|
|
] as const;
|
|
|
|
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">
|
|
<SendouButton
|
|
variant="minimal"
|
|
icon={<CheckmarkIcon title="Save destination" />}
|
|
size="small"
|
|
onPress={handleSubmit}
|
|
/>
|
|
<SendouButton
|
|
variant="minimal-destructive"
|
|
size="small"
|
|
icon={<CrossIcon title="Cancel" />}
|
|
onPress={() => setEditingDestination(false)}
|
|
/>
|
|
</div>
|
|
</td>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{allMatchesFinished &&
|
|
overridenDestination &&
|
|
overridenDestination.idx !== destination?.idx ? (
|
|
<td className="text-theme font-bold">
|
|
<span>→ {overridenDestination.name}</span>
|
|
</td>
|
|
) : destination && overridenDestination !== null ? (
|
|
<td
|
|
className={clsx({
|
|
"italic text-lighter": !allMatchesFinished,
|
|
})}
|
|
>
|
|
<span>→ {destination.name}</span>
|
|
</td>
|
|
) : (
|
|
<td />
|
|
)}
|
|
{canEditDestination ? (
|
|
<td>
|
|
<SendouButton
|
|
variant="minimal"
|
|
icon={<EditIcon title="Edit destination" />}
|
|
size="small"
|
|
onPress={() => setEditingDestination(true)}
|
|
/>
|
|
</td>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SwissDividerRow({
|
|
type,
|
|
threshold,
|
|
columnCount,
|
|
}: {
|
|
type: "qualified" | "eliminated";
|
|
threshold: number;
|
|
columnCount: number;
|
|
}) {
|
|
const isQualified = type === "qualified";
|
|
const message = isQualified
|
|
? `Qualified (@ ${threshold} wins)`
|
|
: `Eliminated (@ ${threshold} losses)`;
|
|
|
|
return (
|
|
<tr className={styles.standingsDividerRow}>
|
|
<td colSpan={columnCount} className={styles.standingsDivider}>
|
|
<div
|
|
className={clsx(styles.standingsDividerContent, {
|
|
[styles.standingsDividerQualified]: isQualified,
|
|
[styles.standingsDividerEliminated]: !isQualified,
|
|
})}
|
|
>
|
|
<div className={styles.standingsDividerLine} />
|
|
<span className={styles.standingsDividerText}>{message}</span>
|
|
<div className={styles.standingsDividerLine} />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|