New results tab for tournaments (#1876)

* Initial

* Try npm in deploy script

* Progress

* Progress

* Progress

* Add missing index

* Title to user results page

* Hide SPR before tournament over

* Results page and redirect

* SPR info

* Row bg colors

* Laoyut shift fix

* Hide Swiss start round button if bracket is in preview

* Fix e2e test

* Not needed

* one more revert
This commit is contained in:
Kalle 2024-09-08 11:25:10 +03:00 committed by GitHub
parent 42c916ecc0
commit d4d6002344
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 405 additions and 353 deletions

View File

@ -1,8 +1,17 @@
import clsx from "clsx";
import { Popover } from "./Popover";
export function InfoPopover({ children }: { children: React.ReactNode }) {
export function InfoPopover({
children,
tiny = false,
}: { children: React.ReactNode; tiny?: boolean }) {
return (
<Popover buttonChildren={<>?</>} triggerClassName="info-popover__trigger">
<Popover
buttonChildren={<>?</>}
triggerClassName={clsx("info-popover__trigger", {
"info-popover__trigger__tiny": tiny,
})}
>
{children}
</Popover>
);

View File

@ -40,15 +40,15 @@ export const Main = ({
className={
classNameOverwrite
? clsx(classNameOverwrite, {
"half-width": halfWidth,
[containerClassName("narrow")]: halfWidth,
"pt-8-forced": showLeaderboard,
})
: clsx(
"layout__main",
"main",
containerClassName("normal"),
{
"half-width": halfWidth,
bigger,
[containerClassName("narrow")]: halfWidth,
[containerClassName("wide")]: bigger,
"pt-8-forced": showLeaderboard,
},
className,
@ -61,3 +61,15 @@ export const Main = ({
</div>
);
};
export const containerClassName = (width: "narrow" | "normal" | "wide") => {
if (width === "narrow") {
return "half-width";
}
if (width === "wide") {
return "bigger";
}
return "main";
};

View File

@ -1,3 +1,7 @@
export function Table({ children }: { children: React.ReactNode }) {
return <table className="my-table">{children}</table>;
return (
<div className="my-table__container">
<table className="my-table">{children}</table>
</div>
);
}

View File

@ -77,7 +77,7 @@ export function SwissBracket({
};
const roundThatCanBeStartedId = () => {
if (!tournament.isOrganizer(user)) return undefined;
if (!tournament.isOrganizer(user) || bracket.preview) return undefined;
for (const round of rounds) {
const matches = bracket.data.match.filter(

View File

@ -1,5 +1,5 @@
import type { ActionFunction } from "@remix-run/node";
import { Link, useRevalidator } from "@remix-run/react";
import { useRevalidator } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { ErrorBoundary } from "react-error-boundary";
@ -7,13 +7,10 @@ import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import { useEventSource } from "remix-utils/sse/react";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { Divider } from "~/components/Divider";
import { Flag } from "~/components/Flag";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Menu } from "~/components/Menu";
import { Placement } from "~/components/Placement";
import { Popover } from "~/components/Popover";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { EyeIcon } from "~/components/icons/Eye";
@ -37,7 +34,7 @@ import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.s
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import { nullFilledArray, removeDuplicates } from "~/utils/arrays";
import { nullFilledArray } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { parseRequestPayload, validate } from "~/utils/remix";
@ -46,8 +43,6 @@ import {
SENDOU_INK_BASE_URL,
tournamentBracketsSubscribePage,
tournamentJoinPage,
tournamentTeamPage,
userPage,
} from "~/utils/urls";
import {
useBracketExpanded,
@ -57,7 +52,7 @@ import {
import { Bracket } from "../components/Bracket";
import { BracketMapListDialog } from "../components/BracketMapListDialog";
import { TournamentTeamActions } from "../components/TournamentTeamActions";
import type { Bracket as BracketType, Standing } from "../core/Bracket";
import type { Bracket as BracketType } from "../core/Bracket";
import * as PreparedMaps from "../core/PreparedMaps";
import * as Swiss from "../core/Swiss";
import type { Tournament } from "../core/Tournament";
@ -460,9 +455,6 @@ export default function TournamentBracketsPage() {
<AddSubsPopOver />
) : null}
</div>
{tournament.ctx.isFinalized || tournament.canFinalize(user) ? (
<FinalStandings />
) : null}
<div className="stack md">
<div className="stack horizontal sm">
<BracketNav bracketIdx={bracketIdx} setBracketIdx={setBracketIdx} />
@ -680,175 +672,6 @@ function AddSubsPopOver() {
);
}
const MAX_PLACEMENT_TO_SHOW = 7;
function FinalStandings() {
const tournament = useTournament();
const { t } = useTranslation(["tournament"]);
const [viewAll, setViewAll] = React.useState(false);
const standings = tournament.standings.filter(
(s) => s.placement <= MAX_PLACEMENT_TO_SHOW,
);
if (standings.length < 2) {
console.error("Unexpectedly few standings");
return null;
}
let [first, second, third, ...rest] = standings;
if (third && third.placement === rest[0]?.placement) {
rest.unshift(third);
third = undefined as unknown as Standing;
}
const onlyTwoTeams = !third;
const nonTopThreePlacements = viewAll
? removeDuplicates(rest.map((s) => s.placement))
: [];
return (
<div className="tournament-bracket__standings">
{[third, first, second].map((standing, i) => {
if (onlyTwoTeams && i === 0) return <div key="placeholder" />;
return (
<div
className="tournament-bracket__standing"
key={standing.team.id}
data-placement={standing.placement}
data-testid={`standing-${standing.placement}`}
>
<div>
<Placement placement={standing.placement} size={40} />
</div>
<Link
to={tournamentTeamPage({
tournamentId: tournament.ctx.id,
tournamentTeamId: standing.team.id,
})}
className="tournament-bracket__standing__team-name tournament-bracket__standing__team-name__big"
>
{standing.team.name}
</Link>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.team.members.map((player) => {
return (
<Link
to={userPage(player)}
key={player.userId}
className="stack items-center text-xs"
data-testid="standing-player"
>
<Avatar user={player} size="xxs" />
</Link>
);
})}
</div>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.team.members.map((player) => {
return (
<div key={player.userId} className="stack items-center">
{player.country ? (
<Flag countryCode={player.country} tiny />
) : null}
<Link
to={userPage(player)}
className="stack items-center text-xs mt-auto"
>
{player.username}
</Link>
</div>
);
})}
</div>
</div>
);
})}
{nonTopThreePlacements.map((placement) => {
return (
<React.Fragment key={placement}>
<Divider className="tournament-bracket__standings__full-row-taker">
<Placement placement={placement} />
</Divider>
<div className="stack xl horizontal flex-wrap justify-center tournament-bracket__standings__full-row-taker">
{standings
.filter((s) => s.placement === placement)
.map((standing) => {
return (
<div
className="tournament-bracket__standing"
key={standing.team.id}
>
<Link
to={tournamentTeamPage({
tournamentId: tournament.ctx.id,
tournamentTeamId: standing.team.id,
})}
className="tournament-bracket__standing__team-name"
>
{standing.team.name}
</Link>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.team.members.map((player) => {
return (
<Link
to={userPage(player)}
key={player.userId}
className="stack items-center text-xs"
>
<Avatar user={player} size="xxs" />
</Link>
);
})}
</div>
<div className="stack horizontal sm flex-wrap justify-center">
{standing.team.members.map((player) => {
return (
<div
key={player.userId}
className="stack items-center"
>
{player.country ? (
<Flag countryCode={player.country} tiny />
) : null}
<Link
to={userPage(player)}
className="stack items-center text-xs mt-auto"
>
{player.username}
</Link>
</div>
);
})}
</div>
</div>
);
})}
</div>
</React.Fragment>
);
})}
{rest.length > 0 ? (
<>
<div />
<Button
variant="outlined"
className="tournament-bracket__standings__show-more"
size="tiny"
onClick={() => setViewAll((v) => !v)}
>
{viewAll
? t("tournament:bracket.standings.showLess")
: t("tournament:bracket.standings.showMore")}
</Button>
</>
) : null}
</div>
);
}
function BracketNav({
bracketIdx,
setBracketIdx,

View File

@ -396,63 +396,6 @@
gap: var(--s-3);
}
.tournament-bracket__standings {
background-color: var(--bg-lighter-transparent);
display: grid;
grid-template-columns: 1fr;
border-radius: var(--rounded);
padding: var(--s-3);
gap: var(--s-2);
margin: 0 auto;
margin-bottom: var(--s-4);
width: 100%;
max-width: 850px;
}
[data-placement="1"] {
order: -1;
}
[data-placement="2"] {
order: -1;
}
@media screen and (min-width: 480px) {
.tournament-bracket__standings {
grid-template-columns: 1fr 1fr 1fr;
}
.tournament-bracket__standings__full-row-taker {
grid-column: 1 / 4;
}
[data-placement="1"] {
order: unset;
}
[data-placement="2"] {
order: unset;
}
}
.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);
color: var(--text);
}
.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

@ -96,6 +96,7 @@ export function TeamWithRoster({
<Link
to={userPage(member)}
className="tournament__team-member-name"
data-testid="team-member-name"
>
{name()}
</Link>

View File

@ -0,0 +1,78 @@
import type { Standing } from "~/features/tournament-bracket/core/Bracket";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { removeDuplicates } from "~/utils/arrays";
/** Calculates SPR (Seed Performance Rating) - see https://www.pgstats.com/articles/introducing-spr-and-uf */
export function calculateSPR({
standings,
teamId,
}: {
standings: Standing[];
teamId: number;
}) {
const uniquePlacements = removeDuplicates(
standings.map((standing) => standing.placement),
).sort((a, b) => a - b);
const teamStanding = standings.find(
(standing) => standing.team.id === teamId,
);
// defensive check to avoid crashing
if (!teamStanding) {
return 0;
}
const expectedPlacement =
standings[(teamStanding.team.seed ?? 0) - 1]?.placement;
// defensive check to avoid crashing
if (!expectedPlacement) {
return 0;
}
const teamPlacement = teamStanding.placement;
const actualIndex = uniquePlacements.indexOf(teamPlacement);
const expectedIndex = uniquePlacements.indexOf(expectedPlacement);
return expectedIndex - actualIndex;
}
/** Teams matches that contributed to the standings, in the order they were played in */
export function matchesPlayed({
tournament,
teamId,
}: { tournament: Tournament; teamId: number }) {
// not considering underground brackets
const brackets = tournament.brackets.filter(
(b) =>
!b.sources || b.sources.some((source) => source.placements.includes(1)),
);
const matches = brackets.flatMap((bracket) =>
bracket.data.match.filter(
(match) =>
match.opponent1 &&
match.opponent2 &&
(match.opponent1?.id === teamId || match.opponent2?.id === teamId),
),
);
return matches.map((match) => {
const opponentId = (
match.opponent1?.id === teamId ? match.opponent2?.id : match.opponent1?.id
)!;
const team = tournament.teamById(opponentId);
const result =
match.opponent1?.id === teamId
? match.opponent1.result
: match.opponent2?.result;
return {
id: match.id,
// defensive fallback
vsSeed: team?.seed ?? 0,
// defensive fallback
result: result ?? "win",
};
});
}

View File

@ -1,5 +1,6 @@
import type { ActionFunction } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
@ -9,6 +10,7 @@ import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { containerClassName } from "~/components/Main";
import { Redirect } from "~/components/Redirect";
import { SubmitButton } from "~/components/SubmitButton";
import { UserSearch } from "~/components/UserSearch";
@ -345,7 +347,7 @@ export default function TournamentAdminPage() {
}
return (
<div className="stack lg">
<div className={clsx("stack lg", containerClassName("normal"))}>
{tournament.isAdmin(user) && !tournament.hasStarted ? (
<div className="stack horizontal items-end">
<LinkButton

View File

@ -1,14 +1,23 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { tournamentBracketsPage, tournamentRegisterPage } from "~/utils/urls";
import {
tournamentBracketsPage,
tournamentRegisterPage,
tournamentResultsPage,
} from "~/utils/urls";
import hasTournamentFinalized from "../queries/hasTournamentFinalized.server";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { tournamentIdFromParams } from "../tournament-utils";
export const loader = ({ params }: LoaderFunctionArgs) => {
const eventId = tournamentIdFromParams(params);
const tournamentId = tournamentIdFromParams(params);
if (!hasTournamentStarted(eventId)) {
throw redirect(tournamentRegisterPage(eventId));
if (!hasTournamentStarted(tournamentId)) {
return redirect(tournamentRegisterPage(tournamentId));
}
throw redirect(tournamentBracketsPage({ tournamentId: eventId }));
if (!hasTournamentFinalized(tournamentId)) {
return redirect(tournamentBracketsPage({ tournamentId }));
}
return redirect(tournamentResultsPage(tournamentId));
};

View File

@ -14,6 +14,7 @@ import { FriendCodeInput } from "~/components/FriendCodeInput";
import { Image, ModeImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { containerClassName } from "~/components/Main";
import { MapPoolStages } from "~/components/MapPoolSelector";
import { NewTabs } from "~/components/NewTabs";
import { Popover } from "~/components/Popover";
@ -76,7 +77,7 @@ export default function TournamentRegisterPage() {
tournament.isOrganizer(user);
return (
<div className="stack lg">
<div className={clsx("stack lg", containerClassName("normal"))}>
<div className="tournament__logo-container">
<img
src={tournament.logoSrc}

View File

@ -0,0 +1,167 @@
import { Link } from "@remix-run/react";
import clsx from "clsx";
import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { InfoPopover } from "~/components/InfoPopover";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import { SPR_INFO_URL, tournamentTeamPage } from "~/utils/urls";
import * as Standings from "../core/Standings";
import { useTournament } from "./to.$id";
export default function TournamentResultsPage() {
const tournament = useTournament();
const standings = tournament.standings;
if (standings.length === 0) {
return (
<div className="text-center text-lg font-semi-bold text-lighter">
No team finished yet, check back later
</div>
);
}
let lastRenderedPlacement = 0;
let rowDarkerBg = false;
return (
<div>
<Table>
<thead>
<tr>
<th>Standing</th>
<th>Team</th>
<th>Roster</th>
<th>Seed</th>
{tournament.ctx.isFinalized ? (
<th
className="stack horizontal sm items-center"
data-testid="spr-header"
>
SPR{" "}
<InfoPopover tiny>
<a
href={SPR_INFO_URL}
target="_blank"
rel="noopener noreferrer"
>
Seed Performance Rating
</a>
</InfoPopover>
</th>
) : null}
<th>Matches</th>
</tr>
</thead>
<tbody>
{standings.map((standing, i) => {
const placement =
lastRenderedPlacement === standing.placement
? null
: standing.placement;
lastRenderedPlacement = standing.placement;
if (standing.placement !== standings[i - 1]?.placement) {
rowDarkerBg = !rowDarkerBg;
}
const teamLogoSrc = tournament.tournamentTeamLogoSrc(standing.team);
const spr = Standings.calculateSPR({
standings,
teamId: standing.team.id,
});
return (
<tr
key={standing.team.id}
className={rowDarkerBg ? "bg-darker-transparent" : undefined}
>
<td className="text-md">
{typeof placement === "number" ? (
<Placement placement={placement} size={36} />
) : null}{" "}
</td>
<td>
<Link
to={tournamentTeamPage({
tournamentId: tournament.ctx.id,
tournamentTeamId: standing.team.id,
})}
className="stack xs horizontal items-center text-main-forced"
data-testid="result-team-name"
>
{teamLogoSrc ? (
<Avatar size="xs" url={teamLogoSrc} />
) : null}{" "}
{standing.team.name}
</Link>
</td>
<td>
{standing.team.members.map((player) => (
<div
key={player.userId}
className="stack xxs horizontal items-center"
>
{player.country ? (
<Flag countryCode={player.country} tiny />
) : null}
{player.username}
</div>
))}
</td>
<td className="text-sm">{standing.team.seed}</td>
{tournament.ctx.isFinalized ? (
<td className="text-sm">
{spr > 0 ? "+" : ""}
{spr}
</td>
) : null}
<td>
<MatchHistoryRow teamId={standing.team.id} />
</td>
</tr>
);
})}
</tbody>
</Table>
</div>
);
}
function MatchHistoryRow({ teamId }: { teamId: number }) {
const tournament = useTournament();
const teamMatches = Standings.matchesPlayed({
tournament,
teamId,
});
return (
<div className="stack horizontal xs">
{teamMatches.map((match) => {
return (
<MatchResultSquare result={match.result} key={match.id}>
{match.vsSeed}
</MatchResultSquare>
);
})}
</div>
);
}
function MatchResultSquare({
result,
children,
}: { result: "win" | "loss"; children: React.ReactNode }) {
return (
<div
className={clsx("tournament__standings__match-result-square", {
"tournament__standings__match-result-square--win": result === "win",
"tournament__standings__match-result-square--loss": result === "loss",
})}
>
{children}
</div>
);
}

View File

@ -7,7 +7,6 @@ import {
Outlet,
type ShouldRevalidateFunction,
useLoaderData,
useLocation,
useOutletContext,
} from "@remix-run/react";
import * as React from "react";
@ -178,7 +177,6 @@ export default function TournamentLayout() {
const { t } = useTranslation(["tournament"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const location = useLocation();
const tournament = React.useMemo(
() => new Tournament(data.tournament),
[data],
@ -193,8 +191,6 @@ export default function TournamentLayout() {
}, [tournament]);
}
const onBracketsPage = location.pathname.includes("brackets");
const subsCount = () =>
tournament.ctx.subCounts.reduce((acc, cur) => {
if (cur.visibility === "ALL") return acc + cur.count;
@ -218,7 +214,7 @@ export default function TournamentLayout() {
}, 0);
return (
<Main bigger={onBracketsPage}>
<Main bigger>
<SubNav>
<SubNavLink to="register" data-testid="register-tab" prefetch="intent">
{tournament.hasStarted ? "Info" : t("tournament:tabs.register")}
@ -241,6 +237,11 @@ export default function TournamentLayout() {
})}
</SubNavLink>
) : null}
{tournament.hasStarted ? (
<SubNavLink to="results" data-testid="results-tab">
{t("tournament:tabs.results")}
</SubNavLink>
) : null}
{tournament.isOrganizer(user) && !tournament.hasStarted && (
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
)}

View File

@ -604,3 +604,22 @@
border-bottom: 2px solid var(--theme-transparent);
content: "";
}
.tournament__standings__match-result-square {
width: 28px;
height: 28px;
display: grid;
place-items: center;
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
border: 3px solid var(--theme);
border-radius: var(--rounded-sm);
}
.tournament__standings__match-result-square--win {
border-color: var(--theme-success);
}
.tournament__standings__match-result-square--loss {
border-color: var(--theme-error);
}

View File

@ -380,6 +380,7 @@ export function findResultsByUserId(userId: number) {
.where("TournamentResult.userId", "=", userId),
)
.orderBy("startTime", "desc")
.$narrowType<{ startTime: NotNull }>()
.execute();
}

View File

@ -38,7 +38,6 @@ export function UserResultsTable({
<th id={placementHeaderId}>{t("results.placing")}</th>
<th>{t("results.team")}</th>
<th>{t("results.tournament")}</th>
<th>{t("results.participants")}</th>
<th>{t("results.date")}</th>
<th>{t("results.mates")}</th>
</tr>
@ -70,8 +69,13 @@ export function UserResultsTable({
/>
</td>
)}
<td className="pl-4" id={placementCellId}>
<Placement placement={result.placement} />
<td className="pl-4 whitespace-nowrap" id={placementCellId}>
<div className="stack horizontal xs items-end">
<Placement placement={result.placement} />{" "}
<div className="text-lighter">
/ {result.participantCount}
</div>
</div>
</td>
<td>
{result.tournamentId ? (
@ -104,14 +108,12 @@ export function UserResultsTable({
</Link>
) : null}
</td>
<td>{result.participantCount}</td>
<td>
{/* TODO: can be made better when $narrowNotNull lands */}
{databaseTimestampToDate(result.startTime!).toLocaleDateString(
{databaseTimestampToDate(result.startTime).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "numeric",
month: "long",
year: "numeric",
},
)}

View File

@ -1,7 +1,6 @@
import { useLoaderData, useMatches } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Button, LinkButton } from "~/components/Button";
import { Section } from "~/components/Section";
import { useUser } from "~/features/auth/core/user";
import { UserResultsTable } from "~/features/user-page/components/UserResultsTable";
import { useSearchParamState } from "~/hooks/useSearchParamState";
@ -36,21 +35,21 @@ export default function UserResultsPage() {
return (
<div className="stack lg">
{user?.id === layoutData.user.id ? (
<LinkButton
to={userResultsEditHighlightsPage(user)}
className="ml-auto"
size="tiny"
>
{t("results.highlights.choose")}
</LinkButton>
) : null}
<Section
title={showAll ? t("results.title") : t("results.highlights")}
className="u__results-table-wrapper"
>
<UserResultsTable id="user-results-table" results={resultsToShow} />
</Section>
<div className="stack horizontal justify-between items-center">
<h2 className="text-lg">
{showAll ? t("results.title") : t("results.highlights")}
</h2>
{user?.id === layoutData.user.id ? (
<LinkButton
to={userResultsEditHighlightsPage(user)}
className="ml-auto"
size="tiny"
>
{t("results.highlights.choose")}
</LinkButton>
) : null}
</div>
<UserResultsTable id="user-results-table" results={resultsToShow} />
{hasHighlightedResults ? (
<Button
variant="minimal"

View File

@ -3,7 +3,7 @@ import type {
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { Outlet, useLoaderData, useLocation } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
@ -78,6 +78,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
export default function UserPageLayout() {
const data = useLoaderData<typeof loader>();
const user = useUser();
const location = useLocation();
const { t } = useTranslation(["common", "user"]);
const isOwnPage = data.user.id === user?.id;
@ -86,7 +87,7 @@ export default function UserPageLayout() {
data.user.calendarEventResultsCount + data.user.tournamentResultsCount;
return (
<Main>
<Main bigger={location.pathname.includes("results")}>
<SubNav>
<SubNavLink to={userPage(data.user)}>
{t("common:header.profile")}

View File

@ -54,12 +54,9 @@ export class BaseUpdater extends BaseGetter {
const stage = this.storage.select("stage", stored.stage_id);
if (!stage) throw Error("Stage not found.");
const inRoundRobin = helpers.isRoundRobin(stage);
const { statusChanged, resultChanged } = helpers.setMatchResults(
stored,
match,
inRoundRobin,
);
this.applyMatchUpdate(stored);
@ -248,7 +245,7 @@ export class BaseUpdater extends BaseGetter {
* @param match The current match.
*/
protected propagateByeWinners(match: Match): void {
helpers.setMatchResults(match, match, false); // BYE propagation is only in non round-robin stages.
helpers.setMatchResults(match, match); // BYE propagation is only in non round-robin stages.
this.applyMatchUpdate(match);
if (helpers.hasBye(match)) this.updateRelatedMatches(match, true, true);

View File

@ -545,8 +545,6 @@ export function byeLoser(opponents: Duel, index: number): ParticipantSlot {
export function getMatchResult(match: MatchResults): Side | null {
if (!isMatchCompleted(match)) return null;
if (isMatchDrawCompleted(match)) return null;
if (match.opponent1 === null && match.opponent2 === null) return null;
let winner: Side | null = null;
@ -642,20 +640,7 @@ export function isMatchCompleted(match: DeepPartial<MatchResults>): boolean {
export function isMatchResultCompleted(
match: DeepPartial<MatchResults>,
): boolean {
return isMatchDrawCompleted(match) || isMatchWinCompleted(match);
}
/**
* Checks if a match is completed because of a draw.
*
* @param match Partial match results.
*/
export function isMatchDrawCompleted(
match: DeepPartial<MatchResults>,
): boolean {
return (
match.opponent1?.result === "draw" && match.opponent2?.result === "draw"
);
return isMatchWinCompleted(match);
}
/**
@ -770,17 +755,10 @@ export function getMatchStatus(arg: Duel | MatchResults): Status {
export function setMatchResults(
stored: MatchResults,
match: DeepPartial<MatchResults>,
inRoundRobin: boolean,
): {
statusChanged: boolean;
resultChanged: boolean;
} {
if (
!inRoundRobin &&
(match.opponent1?.result === "draw" || match.opponent2?.result === "draw")
)
throw Error("Having a draw is forbidden in an elimination tournament.");
const completed = isMatchCompleted(match);
const currentlyCompleted = isMatchCompleted(stored);
@ -1127,7 +1105,6 @@ export function setCompleted(
setResults(stored, match, "win", "loss");
setResults(stored, match, "loss", "win");
setResults(stored, match, "draw", "draw");
if (stored.opponent1 && !stored.opponent2) stored.opponent1.result = "win"; // Win against opponent 2 BYE.

View File

@ -185,22 +185,6 @@ describe("Create single elimination stage", () => {
}),
).toThrow("The seeding has a duplicate participant.");
});
test("should throw if trying to set a draw as a result", () => {
manager.create({
name: "Example",
tournamentId: 0,
type: "single_elimination",
seeding: [1, 2, 3, 4],
});
expect(() =>
manager.update.match({
id: 0,
opponent1: { result: "draw" },
}),
).toThrow("Having a draw is forbidden in an elimination tournament.");
});
});
describe("Previous and next match update", () => {

View File

@ -57,4 +57,4 @@ export type SeedOrdering =
/**
* The possible results of a duel for a participant.
*/
export type Result = "win" | "draw" | "loss";
export type Result = "win" | "loss";

View File

@ -149,6 +149,7 @@ function Document({
content="black-translucent"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#010115" />
<Meta />
<Links />

View File

@ -314,22 +314,35 @@ select:focus {
border-spacing: 0 var(--s-1-5);
font-size: var(--fonts-xs);
text-align: left;
border-color: var(--border);
}
.my-table__container {
position: relative;
width: 100%;
overflow: auto;
}
.my-table > thead {
font-size: var(--fonts-xxs);
}
.my-table > tbody > tr:nth-child(2n) {
background-color: var(--bg);
.my-table tr {
border-style: solid;
border-width: 0px;
border-bottom-width: 1px;
}
.my-table tbody tr:hover {
background-color: var(--bg-lighter);
}
.my-table > thead > tr > th {
padding-inline: var(--s-1);
padding: var(--s-2);
}
.my-table > tbody > tr > td {
padding-inline: var(--s-1);
padding: var(--s-2) var(--s-2-5);
}
td > input[type="checkbox"] {
@ -655,6 +668,12 @@ dialog::backdrop {
height: 28px;
}
.info-popover__trigger__tiny {
width: 20px;
height: 20px;
font-size: var(--fonts-xs);
}
.articles-list {
display: flex;
flex-direction: column;

View File

@ -136,7 +136,7 @@
.u__results-players {
display: flex;
flex-wrap: wrap;
flex-direction: column;
padding: 0;
gap: var(--s-3);
list-style: none;

View File

@ -40,9 +40,7 @@ const staticAssetsUrl = ({
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
const USER_SUBMITTED_IMAGE_ROOT =
process.env.NODE_ENV === "development"
? "https://sendou-test.ams3.digitaloceanspaces.com"
: "https://sendou.nyc3.cdn.digitaloceanspaces.com";
"https://sendou.nyc3.cdn.digitaloceanspaces.com";
export const userSubmittedImage = (fileName: string) =>
`${USER_SUBMITTED_IMAGE_ROOT}/${fileName}`;
// images with https are not hosted on spaces, this is used for local development
@ -71,6 +69,8 @@ export const ipLabsMaps = (pool: string) =>
export const SPLATOON_3_INK = "https://splatoon3.ink/";
export const RHODESMAS_FREESOUND_PROFILE_URL =
"https://freesound.org/people/rhodesmas/";
export const SPR_INFO_URL =
"https://www.pgstats.com/articles/introducing-spr-and-uf";
export const twitterUrl = (accountName: string) =>
`https://twitter.com/${accountName}`;
@ -263,6 +263,8 @@ export const tournamentBracketsPage = ({
query.size > 0 ? `?${query.toString()}` : ""
}`;
};
export const tournamentResultsPage = (tournamentId: number) =>
`/to/${tournamentId}/results`;
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
`/to/${tournamentId}/brackets/subscribe`;
export const tournamentMatchPage = ({

View File

@ -323,6 +323,10 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("finalize-tournament-button").click();
await page.getByTestId("confirm-button").click();
await page.getByTestId("results-tab").click();
// seed performance rating shows up after tournament is finalized
await expect(page.getByTestId("spr-header")).toBeVisible();
await navigate({
page,
url: userResultsPage({ discordId: ADMIN_DISCORD_ID }),
@ -424,15 +428,16 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("finalize-tournament-button").click();
await page.getByTestId("confirm-button").click();
await expect(page.getByTestId("standing-1")).toBeVisible();
// not possible to reopen finals match anymore
await navigateToMatch(page, 14);
await isNotVisible(page.getByTestId("reopen-match-button"));
await backToBracket(page);
// added result to user profile
await page.getByTestId("standing-player").first().click();
await page.getByTestId("results-tab").click();
await page.getByTestId("result-team-name").first().click();
await page.getByTestId("team-member-name").first().click();
await page.getByText("Results").click();
await expect(
page.getByTestId("tournament-name-cell").first(),

View File

@ -26,7 +26,6 @@
"results.placing": "Placering",
"results.team": "Hold",
"results.tournament": "Turnering",
"results.participants": "Deltagere",
"results.date": "Dato",
"results.mates": "Holdkammerater",
"results.highlights": "Højdepunkter",

View File

@ -15,7 +15,6 @@
"results.placing": "Platzierung",
"results.team": "Team",
"results.tournament": "Turnier",
"results.participants": "Teilnehmer",
"results.date": "Datum",
"results.mates": "Mitspieler",
"results.highlights": "Highlights",

View File

@ -5,6 +5,7 @@
"tabs.register": "Register",
"tabs.brackets": "Brackets",
"tabs.seeds": "Seeds",
"tabs.results": "Results",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Subs ({{count}})",

View File

@ -26,7 +26,6 @@
"results.placing": "Placing",
"results.team": "Team",
"results.tournament": "Tournament",
"results.participants": "Participants",
"results.date": "Date",
"results.mates": "Mates",
"results.highlights": "Highlights",

View File

@ -22,7 +22,6 @@
"results.placing": "Lugar",
"results.team": "Equipo",
"results.tournament": "Torneo",
"results.participants": "Participantes",
"results.date": "Fecha",
"results.mates": "Compañeros",
"results.highlights": "Resaltos",

View File

@ -26,7 +26,6 @@
"results.placing": "Lugar",
"results.team": "Equipo",
"results.tournament": "Torneo",
"results.participants": "Participantes",
"results.date": "Fecha",
"results.mates": "Compañeros",
"results.highlights": "Resaltos",

View File

@ -22,7 +22,6 @@
"results.placing": "Placement",
"results.team": "Équipe",
"results.tournament": "Tournoi",
"results.participants": "Participants",
"results.date": "Date",
"results.mates": "Équipiers",
"results.highlights": "Résultats notables",

View File

@ -22,7 +22,6 @@
"results.placing": "Placement",
"results.team": "Équipe",
"results.tournament": "Tournoi",
"results.participants": "Participants",
"results.date": "Date",
"results.mates": "Équipiers",
"results.highlights": "Résultats notables",

View File

@ -22,7 +22,6 @@
"results.placing": "מיקום",
"results.team": "צוות",
"results.tournament": "טורניר",
"results.participants": "משתתפים",
"results.date": "תאריך",
"results.mates": "חברי צוות",
"results.highlights": "נקודות שיא",

View File

@ -12,7 +12,6 @@
"results.placing": "Risultato",
"results.team": "Squadra",
"results.tournament": "Torneo",
"results.participants": "Partecipanti",
"results.date": "Data",
"results.mates": "Compagni",
"results.highlights": "Highlights",

View File

@ -25,7 +25,6 @@
"results.placing": "順位",
"results.team": "チーム",
"results.tournament": "トーナメント",
"results.participants": "参加者",
"results.date": "日",
"results.mates": "フレンド",
"results.highlights": "主な戦績",

View File

@ -13,7 +13,6 @@
"results.placing": "Plaatsing",
"results.team": "Team",
"results.tournament": "Toernooi",
"results.participants": "Deelnemers",
"results.date": "Datum",
"results.mates": "Teamleden",

View File

@ -15,7 +15,6 @@
"results.placing": "Placing",
"results.team": "Drużyna",
"results.tournament": "Turniej",
"results.participants": "Uczestnicy",
"results.date": "Data",
"results.mates": "Koledzy",
"results.highlights": "Wyróżnienia",

View File

@ -22,7 +22,6 @@
"results.placing": "Classificação",
"results.team": "Time",
"results.tournament": "Torneio",
"results.participants": "Participantes",
"results.date": "Data",
"results.mates": "Parceiros(as)",
"results.highlights": "Destaques",

View File

@ -22,7 +22,6 @@
"results.placing": "Место",
"results.team": "Команда",
"results.tournament": "Турнир",
"results.participants": "Участники",
"results.date": "Дата",
"results.mates": "Напарники",
"results.highlights": "Избранное",

View File

@ -26,7 +26,6 @@
"results.placing": "排名",
"results.team": "队伍",
"results.tournament": "比赛",
"results.participants": "参加者",
"results.date": "日期",
"results.mates": "队友",
"results.highlights": "高光成绩",

View File

@ -0,0 +1,5 @@
export function up(db) {
db.prepare(
/*sql*/ `create index tournament_result_tournament_team_id on "TournamentResult"("tournamentTeamId")`,
).run();
}

View File

@ -141,6 +141,10 @@ export default defineConfig(() => {
"/to/:id/seeds",
"features/tournament/routes/to.$id.seeds.tsx",
);
route(
"/to/:id/results",
"features/tournament/routes/to.$id.results.tsx",
);
route(
"/to/:id/streams",
"features/tournament/routes/to.$id.streams.tsx",