mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 07:32:19 -05:00
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:
parent
42c916ecc0
commit
d4d6002344
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export function TeamWithRoster({
|
|||
<Link
|
||||
to={userPage(member)}
|
||||
className="tournament__team-member-name"
|
||||
data-testid="team-member-name"
|
||||
>
|
||||
{name()}
|
||||
</Link>
|
||||
|
|
|
|||
78
app/features/tournament/core/Standings.ts
Normal file
78
app/features/tournament/core/Standings.ts
Normal 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",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
167
app/features/tournament/routes/to.$id.results.tsx
Normal file
167
app/features/tournament/routes/to.$id.results.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -380,6 +380,7 @@ export function findResultsByUserId(userId: number) {
|
|||
.where("TournamentResult.userId", "=", userId),
|
||||
)
|
||||
.orderBy("startTime", "desc")
|
||||
.$narrowType<{ startTime: NotNull }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@
|
|||
|
||||
.u__results-players {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
gap: var(--s-3);
|
||||
list-style: none;
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"tabs.register": "Register",
|
||||
"tabs.brackets": "Brackets",
|
||||
"tabs.seeds": "Seeds",
|
||||
"tabs.results": "Results",
|
||||
"tabs.streams": "Streams ({{count}})",
|
||||
"tabs.subs": "Subs ({{count}})",
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
"results.placing": "מיקום",
|
||||
"results.team": "צוות",
|
||||
"results.tournament": "טורניר",
|
||||
"results.participants": "משתתפים",
|
||||
"results.date": "תאריך",
|
||||
"results.mates": "חברי צוות",
|
||||
"results.highlights": "נקודות שיא",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@
|
|||
"results.placing": "順位",
|
||||
"results.team": "チーム",
|
||||
"results.tournament": "トーナメント",
|
||||
"results.participants": "参加者",
|
||||
"results.date": "日",
|
||||
"results.mates": "フレンド",
|
||||
"results.highlights": "主な戦績",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
"results.placing": "Plaatsing",
|
||||
"results.team": "Team",
|
||||
"results.tournament": "Toernooi",
|
||||
"results.participants": "Deelnemers",
|
||||
"results.date": "Datum",
|
||||
"results.mates": "Teamleden",
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
"results.placing": "Место",
|
||||
"results.team": "Команда",
|
||||
"results.tournament": "Турнир",
|
||||
"results.participants": "Участники",
|
||||
"results.date": "Дата",
|
||||
"results.mates": "Напарники",
|
||||
"results.highlights": "Избранное",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@
|
|||
"results.placing": "排名",
|
||||
"results.team": "队伍",
|
||||
"results.tournament": "比赛",
|
||||
"results.participants": "参加者",
|
||||
"results.date": "日期",
|
||||
"results.mates": "队友",
|
||||
"results.highlights": "高光成绩",
|
||||
|
|
|
|||
5
migrations/068-add-missing-user-results-index.js
Normal file
5
migrations/068-add-missing-user-results-index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function up(db) {
|
||||
db.prepare(
|
||||
/*sql*/ `create index tournament_result_tournament_team_id on "TournamentResult"("tournamentTeamId")`,
|
||||
).run();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user