Progress incl. tournament admin tab

This commit is contained in:
Kalle 2026-04-24 19:41:42 +03:00
parent cf239cf993
commit caacdec476
81 changed files with 1220 additions and 3222 deletions

View File

@ -47,6 +47,7 @@
- one file containing React code should have a matching CSS module file e.g. `Component.tsx` should have a file with the same root name i.e. `Component.module.css`
- clsx library is used for conditional class names
- prefer using [CSS variables](./app/styles/vars.css) for theming
- for any CSS variable used, make sure it is defined either locally or in the `vars.css` file
- for simple styling, prefer [utility classes](./app/styles/utils.css) over creating a new class
- use CSS nesting with the `&` selector to group related selectors (pseudo-classes, pseudo-elements, child selectors, attribute selectors) under their parent instead of repeating the parent selector

View File

@ -111,7 +111,7 @@ export function MatchActionTab({
) : null}
<RadioGroup
value={winnerId !== null ? String(winnerId) : undefined}
value={winnerId !== null ? String(winnerId) : null}
onChange={(value) => {
const selectedId = Number(value);
setWinnerId(selectedId);

View File

@ -1,4 +1,4 @@
import { DoorOpen, ScrollText, Swords, Tally5, Users } from "lucide-react";
import { DoorOpen, Key, ScrollText, Swords, Tally5, Users } from "lucide-react";
import type * as React from "react";
import { useSearchParams } from "react-router";
import invariant from "~/utils/invariant";
@ -19,6 +19,7 @@ export const TAB_KEYS = {
ACTION: "action",
JOIN: "join",
RESULT: "result",
ADMIN: "admin",
} as const;
const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
@ -27,6 +28,7 @@ const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
action: <Tally5 />,
join: <DoorOpen />,
result: <ScrollText />,
admin: <Key />,
};
const TAB_LABELS: Record<MatchTabsKey, string> = {
@ -35,6 +37,7 @@ const TAB_LABELS: Record<MatchTabsKey, string> = {
action: "Action",
join: "Join",
result: "Result",
admin: "Admin",
};
export function MatchTabs({ children, tabs }: MatchTabsProps) {
@ -49,7 +52,13 @@ export function MatchTabs({ children, tabs }: MatchTabsProps) {
<SendouTabs
selectedKey={currentTab}
onSelectionChange={(key) =>
setSearchParams({ [TAB_KEY]: key as string })
setSearchParams(
{ [TAB_KEY]: key as string },
{
preventScrollReset: true,
unstable_defaultShouldRevalidate: false,
},
)
}
disappearing={false}
>

View File

@ -16,6 +16,7 @@ import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
import { ModeImage, StageImage } from "../Image";
import styles from "./MatchTimeline.module.css";
import { type InferredSubstitution, inferSubstitutions } from "./utils";
import { WeaponPool } from "./WeaponPool";
// xxx: timeline also for a set thats still in progress? instead of the separate pick ban tab
@ -71,7 +72,6 @@ export interface MatchTimelineProps {
compact?: boolean;
}
// xxx: need to show Pick/Bans somewhere, on tab?
export function MatchTimeline({
teams,
score,
@ -87,7 +87,7 @@ export function MatchTimeline({
: maps.map((map, i) => {
const previousMap = maps[i - 1];
const substitutions = previousMap
? inferSubstitutions(previousMap, map)
? inferSubstitutions(previousMap.rosters, map.rosters)
: [];
return (
@ -106,40 +106,6 @@ export function MatchTimeline({
);
}
interface InferredSubstitution {
side: MatchSide;
playerOut: CommonUser;
playerIn: CommonUser;
}
// xxx: unit test
function inferSubstitutions(
previousMap: TimelineMap,
currentMap: TimelineMap,
): InferredSubstitution[] {
const result: InferredSubstitution[] = [];
for (const side of ["alpha", "bravo"] as const) {
const prevIds = new Set(previousMap.rosters[side].map((u) => u.id));
const currIds = new Set(currentMap.rosters[side].map((u) => u.id));
const out = previousMap.rosters[side].filter((u) => !currIds.has(u.id));
const inn = currentMap.rosters[side].filter((u) => !prevIds.has(u.id));
for (let i = 0; i < Math.max(out.length, inn.length); i++) {
if (out[i] && inn[i]) {
result.push({
side: side === "alpha" ? "ALPHA" : "BRAVO",
playerOut: out[i],
playerIn: inn[i],
});
}
}
}
return result;
}
function TimelineHeader({
teams,
score,

View File

@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import type { CommonUser } from "~/utils/kysely.server";
import { inferSubstitutions } from "./utils";
function user(id: number): CommonUser {
return {
id,
username: `user${id}`,
discordId: `discord${id}`,
discordAvatar: null,
customUrl: null,
};
}
describe("inferSubstitutions", () => {
it("returns an empty array when rosters are unchanged", () => {
const rosters = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6), user(7), user(8)],
};
expect(inferSubstitutions(rosters, rosters)).toEqual([]);
});
it("detects a single substitution on alpha", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6), user(7), user(8)],
};
const current = {
alpha: [user(1), user(2), user(3), user(9)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(4), playerIn: user(9) },
]);
});
it("detects substitutions on both sides in the same map transition", () => {
const previous = {
alpha: [user(1), user(2)],
bravo: [user(3), user(4)],
};
const current = {
alpha: [user(1), user(10)],
bravo: [user(11), user(4)],
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(2), playerIn: user(10) },
{ side: "BRAVO", playerOut: user(3), playerIn: user(11) },
]);
});
it("pairs multiple substitutions on the same side by roster order", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(10), user(3), user(11)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(2), playerIn: user(10) },
{ side: "ALPHA", playerOut: user(4), playerIn: user(11) },
]);
});
it("ignores unpaired leavers when no new player joined", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(2), user(3)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([]);
});
it("ignores unpaired joiners when no player left", () => {
const previous = {
alpha: [user(1), user(2), user(3)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(2), user(3), user(9)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([]);
});
it("treats players switching sides as separate substitutions on each side", () => {
const previous = {
alpha: [user(1), user(2)],
bravo: [user(3), user(4)],
};
const current = {
alpha: [user(3), user(4)],
bravo: [user(1), user(2)],
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(1), playerIn: user(3) },
{ side: "ALPHA", playerOut: user(2), playerIn: user(4) },
{ side: "BRAVO", playerOut: user(3), playerIn: user(1) },
{ side: "BRAVO", playerOut: user(4), playerIn: user(2) },
]);
});
});

View File

@ -0,0 +1,47 @@
import type { CommonUser } from "~/utils/kysely.server";
type MatchSide = "ALPHA" | "BRAVO";
type Rosters = {
alpha: CommonUser[];
bravo: CommonUser[];
};
export interface InferredSubstitution {
side: MatchSide;
playerOut: CommonUser;
playerIn: CommonUser;
}
/**
* Compares the rosters of two consecutive maps and pairs up any
* players that dropped from a side with new players that joined the same side.
* The pairs are returned in roster order, so the first player out is paired with
* the first new player in. When the counts don't match, unpaired players are ignored.
*/
export function inferSubstitutions(
previousRosters: Rosters,
currentRosters: Rosters,
): InferredSubstitution[] {
const result: InferredSubstitution[] = [];
for (const side of ["alpha", "bravo"] as const) {
const prevIds = new Set(previousRosters[side].map((u) => u.id));
const currIds = new Set(currentRosters[side].map((u) => u.id));
const out = previousRosters[side].filter((u) => !currIds.has(u.id));
const inn = currentRosters[side].filter((u) => !prevIds.has(u.id));
for (let i = 0; i < Math.max(out.length, inn.length); i++) {
if (out[i] && inn[i]) {
result.push({
side: side === "alpha" ? "ALPHA" : "BRAVO",
playerOut: out[i],
playerIn: inn[i],
});
}
}
}
return result;
}

View File

@ -23,7 +23,6 @@ import {
} from "~/features/plus-voting/core";
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { calculateMatchSkills } from "~/features/sendouq-match/core/skills.server";
import {
@ -3033,9 +3032,7 @@ async function playedMatches() {
["ALPHA", "BRAVO", "ALPHA", "BRAVO", "BRAVO", "BRAVO"],
]) as ("ALPHA" | "BRAVO")[];
const winner = winnersArrayToWinner(winners);
const finishedMatch = SendouQ.mapMatch(
(await SQMatchRepository.findById(match.id))!,
);
const finishedMatch = (await SQMatchRepository.findById(match.id))!;
const { newSkills, differences } = calculateMatchSkills({
groupMatchId: match.id,

View File

@ -27,6 +27,7 @@ import {
import { assertUnreachable } from "~/utils/types";
import { sendouQMatchPage } from "~/utils/urls";
import * as RejoinVote from "../core/RejoinVote";
import * as SendouQMatch from "../core/SendouQMatch";
import { matchSchema, qMatchPageParamsSchema } from "../q-match-schemas";
export const action = async ({ request, params }: ActionFunctionArgs) => {
@ -42,10 +43,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
switch (data._action) {
case "REPORT_SCORE": {
const unmappedMatch = notFoundIfFalsy(
await SQMatchRepository.findById(matchId),
);
const match = SendouQ.mapMatch(unmappedMatch, user);
const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId));
const members = [
...match.groupAlpha.members,
@ -154,13 +152,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
case "CAST_CONTINUE_VOTE": {
const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId));
// xxx: some SendouQMatch module util
const viewerSide: "ALPHA" | "BRAVO" | null =
match.groupAlpha.members.some((m) => m.id === user.id)
? "ALPHA"
: match.groupBravo.members.some((m) => m.id === user.id)
? "BRAVO"
: null;
const viewerSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: match.groupAlpha,
groupBravo: match.groupBravo,
userId: user.id,
});
errorToastIfFalsy(viewerSide, "Not a participant");
const viewerGroup =

View File

@ -0,0 +1,108 @@
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SENDOUQ_PAGE } from "~/utils/urls";
import * as RejoinVote from "../core/RejoinVote";
import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
import { RematchVotePanel } from "./RematchVotePanel";
export function MatchmadeRejoinSection({
data,
viewerGroup,
viewerUserId,
awaitingConfirmation,
isOnReporterTeam,
}: {
data: SendouQMatchLoaderData;
viewerGroup: NonNullable<SendouQMatchLoaderData["match"]["groupAlpha"]>;
viewerUserId: number;
awaitingConfirmation: boolean;
isOnReporterTeam: boolean;
}) {
const voteFetcher = useFetcher();
const votes = RejoinVote.extractOwnGroupVotesFromSendouqMatch(
data.match,
viewerUserId,
);
if (!votes) return null;
if (RejoinVote.userContinueStatus(votes, viewerUserId) === false) {
return <DeclinedSection />;
}
// During awaiting confirmation, only reporter team can cascade.
if (awaitingConfirmation && !isOnReporterTeam) return null;
return (
<RematchVotePanel
members={viewerGroup.members.map((m) => ({
id: m.id,
username: m.username,
discordId: m.discordId,
discordAvatar: m.discordAvatar,
customUrl: m.customUrl,
}))}
votes={votes}
viewerUserId={viewerUserId}
fetcher={voteFetcher}
/>
);
}
export function TrustedRejoinSection({
viewerGroup,
viewerUserId,
}: {
viewerGroup: NonNullable<SendouQMatchLoaderData["match"]["groupAlpha"]>;
viewerUserId: number;
}) {
const { t } = useTranslation(["q"]);
const viewerRole = viewerGroup.members.find(
(m) => m.id === viewerUserId,
)?.role;
const lookAgainFetcher = useFetcher();
if (viewerRole === "OWNER") {
return (
<div className="stack md items-center">
<SendouButton
variant="primary"
isPending={lookAgainFetcher.state !== "idle"}
onPress={() => {
lookAgainFetcher.submit(
{
_action: "LOOK_AGAIN",
previousGroupId: String(viewerGroup.id),
},
{ method: "post" },
);
}}
>
{t("q:match.actions.lookAgain")}
</SendouButton>
</div>
);
}
return (
<p className="text-lighter text-sm text-center">
{t("q:match.rematch.waitingCaptain")}
</p>
);
}
function DeclinedSection() {
const { t } = useTranslation(["q"]);
return (
<div className="stack md items-center">
<p className="text-lighter text-sm text-center">
{t("q:match.rematch.declined")}
</p>
<Link to={SENDOUQ_PAGE} className="text-sm">
{t("q:match.rematch.rejoinQueue")}
</Link>
</div>
);
}

View File

@ -1,7 +1,9 @@
import { Check, Clock, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FetcherWithComponents } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import * as RejoinVote from "../core/RejoinVote";
import styles from "./RematchVotePanel.module.css";
@ -17,21 +19,19 @@ type RematchVotePanelProps = {
members: RematchVoteMember[];
votes: RejoinVote.RejoinVote[];
viewerUserId: number;
isPending: boolean;
onVote: (isContinuing: boolean) => void;
fetcher: FetcherWithComponents<any>;
};
// xxx: if Voting no, form with confirm with a warning they cant later change their mind?
export function RematchVotePanel({
members,
votes,
viewerUserId,
isPending,
onVote,
fetcher,
}: RematchVotePanelProps) {
const { t } = useTranslation(["q"]);
const isPending = fetcher.state !== "idle";
const currentRoundSize = RejoinVote.currentUserIds(
votes,
members.map((m) => m.id),
@ -56,14 +56,23 @@ export function RematchVotePanel({
</ul>
{RejoinVote.userContinueStatus(votes, viewerUserId) === false ? null : (
<div className={styles.buttons}>
<SendouButton
variant="outlined"
size="small"
isDisabled={isPending}
onPress={() => onVote(false)}
<FormWithConfirm
fields={[
["_action", "CAST_CONTINUE_VOTE"],
["isContinuing", "0"],
]}
dialogHeading={t("q:match.rematch.vote.noConfirm")}
submitButtonText={t("q:match.rematch.vote.no")}
fetcher={fetcher}
>
{t("q:match.rematch.vote.no")}
</SendouButton>
<SendouButton
variant="outlined"
size="small"
isDisabled={isPending}
>
{t("q:match.rematch.vote.no")}
</SendouButton>
</FormWithConfirm>
<SendouButton
variant="primary"
size="small"
@ -71,7 +80,15 @@ export function RematchVotePanel({
isPending ||
RejoinVote.userContinueStatus(votes, viewerUserId) === true
}
onPress={() => onVote(true)}
onPress={() =>
fetcher.submit(
{
_action: "CAST_CONTINUE_VOTE",
isContinuing: "1",
},
{ method: "post" },
)
}
>
{t("q:match.rematch.vote.yes")}
</SendouButton>

View File

@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import { Ban, Undo2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import { useFetcher } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouTabPanel } from "~/components/elements/Tabs";
import { FormWithConfirm } from "~/components/FormWithConfirm";
@ -15,20 +16,17 @@ import type {
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { SENDOUQ_PAGE } from "~/utils/urls";
import {
resolveGroupNames,
resolveMatchScore,
resolveTimelineMaps,
resolveTimelineTeams,
} from "../core/match-timeline";
import * as RejoinVote from "../core/RejoinVote";
import * as SendouQMatch from "../core/SendouQMatch";
import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
import { RematchVotePanel } from "./RematchVotePanel";
import { MatchmadeRejoinSection, TrustedRejoinSection } from "./RejoinSections";
import styles from "./SendouQMatchActionTab.module.css";
// xxx: maybe divide Rejoin related components to a different file?
export function SendouQMatchActionTab({
data,
currentMap,
@ -53,14 +51,17 @@ export function SendouQMatchActionTab({
const awaitingConfirmation = !data.match.isLocked && isDecisive;
const isLocked = data.match.isLocked;
const cancelRequesterIsAlpha = data.match.groupAlpha.members.some(
(m) => m.id === data.match.cancelRequestedByUserId,
);
const cancelRequestedByGroupId = data.match.cancelRequestedByUserId
? cancelRequesterIsAlpha
const cancelRequesterSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: data.match.groupAlpha,
groupBravo: data.match.groupBravo,
userId: data.match.cancelRequestedByUserId,
});
const cancelRequestedByGroupId =
cancelRequesterSide === "ALPHA"
? data.match.groupAlpha.id
: data.match.groupBravo.id
: undefined;
: cancelRequesterSide === "BRAVO"
? data.match.groupBravo.id
: undefined;
// xxx: system messages for cancel sent, rejected or accepted and by who
if (
@ -182,10 +183,11 @@ function RequeueTab({
isStaffOnly: boolean;
awaitingConfirmation: boolean;
}) {
const { t } = useTranslation(["q"]);
const user = useUser();
const score = resolveMatchScore(data.match);
const teams = resolveTimelineTeams(data.match);
const teams = resolveTimelineTeams(data.match, t);
const maps = resolveTimelineMaps(data.match, data.reportedWeapons);
const viewerGroup =
@ -198,13 +200,11 @@ function RequeueTab({
const decidingReportedByUserId = [...data.match.mapList]
.reverse()
.find((m) => m.winnerGroupId !== null)?.reportedByUserId;
const reporterSide: "ALPHA" | "BRAVO" | null = decidingReportedByUserId
? data.match.groupAlpha.members.some(
(m) => m.id === decidingReportedByUserId,
)
? "ALPHA"
: "BRAVO"
: null;
const reporterSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: data.match.groupAlpha,
groupBravo: data.match.groupBravo,
userId: decidingReportedByUserId,
});
const isOnReporterTeam = awaitingConfirmation && reporterSide === viewerSide;
const isOnConfirmerTeam =
awaitingConfirmation &&
@ -366,116 +366,6 @@ function ReporterUndoSection() {
);
}
function MatchmadeRejoinSection({
data,
viewerGroup,
viewerUserId,
awaitingConfirmation,
isOnReporterTeam,
}: {
data: SendouQMatchLoaderData;
viewerGroup: NonNullable<SendouQMatchLoaderData["match"]["groupAlpha"]>;
viewerUserId: number;
awaitingConfirmation: boolean;
isOnReporterTeam: boolean;
}) {
const voteFetcher = useFetcher();
const votes = RejoinVote.extractOwnGroupVotesFromSendouqMatch(
data.match,
viewerUserId,
);
if (!votes) return null;
if (RejoinVote.userContinueStatus(votes, viewerUserId) === false) {
return <DeclinedSection />;
}
// During awaiting confirmation, only reporter team can cascade.
if (awaitingConfirmation && !isOnReporterTeam) return null;
return (
<RematchVotePanel
members={viewerGroup.members.map((m) => ({
id: m.id,
username: m.username,
discordId: m.discordId,
discordAvatar: m.discordAvatar,
customUrl: m.customUrl,
}))}
votes={votes}
viewerUserId={viewerUserId}
isPending={voteFetcher.state !== "idle"}
onVote={(isContinuing) => {
voteFetcher.submit(
{
_action: "CAST_CONTINUE_VOTE",
isContinuing: String(Number(isContinuing)),
},
{ method: "post" },
);
}}
/>
);
}
function TrustedRejoinSection({
viewerGroup,
viewerUserId,
}: {
viewerGroup: NonNullable<SendouQMatchLoaderData["match"]["groupAlpha"]>;
viewerUserId: number;
}) {
const { t } = useTranslation(["q"]);
const viewerRole = viewerGroup.members.find(
(m) => m.id === viewerUserId,
)?.role;
const lookAgainFetcher = useFetcher();
if (viewerRole === "OWNER") {
return (
<div className="stack md items-center">
<SendouButton
variant="primary"
isPending={lookAgainFetcher.state !== "idle"}
onPress={() => {
lookAgainFetcher.submit(
{
_action: "LOOK_AGAIN",
previousGroupId: String(viewerGroup.id),
},
{ method: "post" },
);
}}
>
{t("q:match.actions.lookAgain")}
</SendouButton>
</div>
);
}
return (
<p className="text-lighter text-sm text-center">
{t("q:match.rematch.waitingCaptain")}
</p>
);
}
function DeclinedSection() {
const { t } = useTranslation(["q"]);
return (
<div className="stack md items-center">
<p className="text-lighter text-sm text-center">
{t("q:match.rematch.declined")}
</p>
<Link to={SENDOUQ_PAGE} className="text-sm">
{t("q:match.rematch.rejoinQueue")}
</Link>
</div>
);
}
function InProgressTab({
data,
currentMap,
@ -521,6 +411,7 @@ function InProgressTab({
...buildSendouQSetEndingData({
match: data.match,
scores,
t,
}),
setEndingTeamIds,
}
@ -538,12 +429,14 @@ function InProgressTab({
.map((w) => w.weaponSplId)
: [];
const groupNames = resolveGroupNames(data.match, t);
return (
<MatchActionTab
key={reportedCount}
teams={[
{ id: data.match.groupAlpha.id, name: "Group Alpha" },
{ id: data.match.groupBravo.id, name: "Group Bravo" },
{ id: data.match.groupAlpha.id, name: groupNames.alpha },
{ id: data.match.groupBravo.id, name: groupNames.bravo },
]}
ownTeamId={ownTeamId}
stageId={currentMap.stageId}
@ -645,9 +538,11 @@ function InProgressTab({
function buildSendouQSetEndingData({
match,
scores,
t,
}: {
match: SendouQMatchLoaderData["match"];
scores: [number, number];
t: TFunction<["q"]>;
}) {
const completedMaps = match.mapList.filter((m) => m.winnerGroupId !== null);
@ -665,20 +560,8 @@ function buildSendouQSetEndingData({
},
}));
const alphaTeam = match.groupAlpha.team;
const bravoTeam = match.groupBravo.team;
return {
teams: {
alpha: {
name: alphaTeam?.name ?? "Group Alpha",
avatar: alphaTeam?.avatarUrl ?? undefined,
},
bravo: {
name: bravoTeam?.name ?? "Group Bravo",
avatar: bravoTeam?.avatarUrl ?? undefined,
},
},
teams: resolveTimelineTeams(match, t),
score: { alpha: scores[0], bravo: scores[1] },
maps: previousMaps,
currentRosters: {

View File

@ -19,6 +19,7 @@ import { useAutoRerender } from "~/hooks/useAutoRerender";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { resolveGroupNames } from "../core/match-timeline";
import * as SendouQMatch from "../core/SendouQMatch";
import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
@ -26,13 +27,16 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
const { t } = useTranslation(["q"]);
const cancelRequested = Boolean(data.match.cancelRequestedByUserId);
const cancelRequesterIsAlpha = data.match.groupAlpha.members.some(
(m) => m.id === data.match.cancelRequestedByUserId,
);
const cancelRequesterSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: data.match.groupAlpha,
groupBravo: data.match.groupBravo,
userId: data.match.cancelRequestedByUserId,
});
const groupNames = resolveGroupNames(data.match, t);
const cancelRequesterName = cancelRequested
? cancelRequesterIsAlpha
? (data.match.groupAlpha.team?.name ?? "Group Alpha")
: (data.match.groupBravo.team?.name ?? "Group Bravo")
? cancelRequesterSide === "ALPHA"
? groupNames.alpha
: groupNames.bravo
: undefined;
const bottomRow = (

View File

@ -10,7 +10,7 @@ import { useUser } from "~/features/auth/core/user";
import { DISPLAY_VOTE_RESULT_SECONDS } from "~/features/sendouq/q-constants";
import { resolveRoomPass } from "~/features/tournament-match/tournament-match-utils";
import { useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { safeNumberParse } from "~/utils/number";
import { SENDOUQ_LOOKING_PAGE, sendouQMatchPage, teamPage } from "~/utils/urls";
import {
@ -20,7 +20,6 @@ import {
} from "../core/match-timeline";
import * as SendouQMatch from "../core/SendouQMatch";
import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
import { resolveGroupMemberOf } from "../q-match-utils";
import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog";
import { SendouQMatchActionTab } from "./SendouQMatchActionTab";
@ -34,7 +33,7 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
const currentMap = data.match.currentMap;
const userSide = resolveGroupMemberOf({
const userSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: data.match.groupAlpha,
groupBravo: data.match.groupBravo,
userId: user?.id,
@ -62,10 +61,10 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
return <Redirect to={SENDOUQ_LOOKING_PAGE} />;
}
const now = Math.floor(Date.now() / 1000);
const lockedVoteVisible =
data.match.confirmedAt !== null &&
now < data.match.confirmedAt + DISPLAY_VOTE_RESULT_SECONDS;
databaseTimestampNow() <
data.match.confirmedAt + DISPLAY_VOTE_RESULT_SECONDS;
const matchInProgress = !isLocked && !awaitingConfirmation && currentMap;
@ -129,7 +128,7 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
<MatchTabs tabs={tabs}>
{isLocked ? (
<MatchResultTab
teams={resolveTimelineTeams(data.match)}
teams={resolveTimelineTeams(data.match, t)}
score={{ alpha: alphaWins, bravo: bravoWins }}
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
spChanges={resolveTimelineSpChanges(data.match)}

View File

@ -1,5 +1,6 @@
import type { SQMatch } from "~/features/sendouq/core/SendouQ.server";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import * as SendouQMatch from "./SendouQMatch";
export interface RejoinVote {
userId: number;
@ -46,13 +47,17 @@ export function extractOwnGroupVotesFromSendouqMatch(
match: Pick<SQMatch, "groupAlpha" | "groupBravo">,
userId: number,
): RejoinVote[] | null {
const ownGroup = match.groupAlpha.members.some(
(member) => member.id === userId,
)
? match.groupAlpha
: match.groupBravo.members.some((member) => member.id === userId)
? match.groupBravo
: null;
const ownSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: match.groupAlpha,
groupBravo: match.groupBravo,
userId,
});
const ownGroup =
ownSide === "ALPHA"
? match.groupAlpha
: ownSide === "BRAVO"
? match.groupBravo
: null;
if (!ownGroup) return null;

View File

@ -20,3 +20,25 @@ export function score(match: {
isDecisive: alphaWins >= mapsToWin || bravoWins >= mapsToWin,
};
}
/**
* Returns which side ("ALPHA" or "BRAVO") of the match the given user belongs
* to, or null if they are not a member of either group.
*/
export function resolveGroupMemberOf(args: {
groupAlpha: { members: { id: number }[] };
groupBravo: { members: { id: number }[] };
userId: number | null | undefined;
}): "ALPHA" | "BRAVO" | null {
if (!args.userId) return null;
if (args.groupAlpha.members.some((m) => m.id === args.userId)) {
return "ALPHA";
}
if (args.groupBravo.members.some((m) => m.id === args.userId)) {
return "BRAVO";
}
return null;
}

View File

@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import type {
TimelineMap,
TimelineSpChanges,
@ -8,14 +9,27 @@ type MatchData = SendouQMatchLoaderData["match"];
// xxx: unit test this file
export function resolveTimelineTeams(match: MatchData) {
/**
* Resolves display names for the two groups in a match, falling back to the
* translated "Group Alpha"/"Group Bravo" labels when a group is not associated
* with a registered team.
*/
export function resolveGroupNames(match: MatchData, t: TFunction<["q"]>) {
return {
alpha: match.groupAlpha.team?.name ?? t("q:match.groupAlpha"),
bravo: match.groupBravo.team?.name ?? t("q:match.groupBravo"),
};
}
export function resolveTimelineTeams(match: MatchData, t: TFunction<["q"]>) {
const names = resolveGroupNames(match, t);
return {
alpha: {
name: match.groupAlpha.team?.name ?? "Group Alpha", // xxx: should be in the loader?
name: names.alpha,
avatar: match.groupAlpha.team?.avatarUrl ?? undefined,
},
bravo: {
name: match.groupBravo.team?.name ?? "Group Bravo",
name: names.bravo,
avatar: match.groupBravo.team?.avatarUrl ?? undefined,
},
};

View File

@ -1,7 +1,4 @@
import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type * as ReportedWeaponRepository from "../ReportedWeaponRepository.server";
import type * as SQMatchRepository from "../SQMatchRepository.server";
export type ReportedWeaponForMerging = {
weaponSplId?: MainWeaponId;
@ -58,43 +55,3 @@ export function mergeReportedWeapons({
typeof w.weaponSplId === "number" ? [w as ReportedWeapon] : [],
);
}
export function reportedWeaponsToArrayOfArrays({
reportedWeapons,
mapList,
groupAlpha,
groupBravo,
}: {
reportedWeapons: Awaited<
ReturnType<typeof ReportedWeaponRepository.findByMatchId>
>;
mapList: NonNullable<
Awaited<ReturnType<typeof SQMatchRepository.findById>>
>["mapList"];
groupAlpha: SQMatchGroup;
groupBravo: SQMatchGroup;
}) {
if (!reportedWeapons) return null;
const result: (MainWeaponId | null)[][] = [];
const allMembers = [...groupAlpha.members, ...groupBravo.members].map(
(m) => m.id,
);
for (const map of mapList) {
const mapWeapons: (MainWeaponId | null)[] = [];
for (const userId of allMembers) {
const reportedWeapon = reportedWeapons.find(
(wpn) => wpn.groupMatchMapId === map.id && wpn.userId === userId,
);
mapWeapons.push(reportedWeapon ? reportedWeapon.weaponSplId : null);
}
result.push(mapWeapons);
}
return result;
}

View File

@ -7,21 +7,3 @@ export function winnersArrayToWinner(winners: ("ALPHA" | "BRAVO")[]) {
return null;
}
export function resolveGroupMemberOf(args: {
groupAlpha: { members: { id: number }[] };
groupBravo: { members: { id: number }[] };
userId: number | undefined;
}): "ALPHA" | "BRAVO" | null {
if (!args.userId) return null;
if (args.groupAlpha.members.some((m) => m.id === args.userId)) {
return "ALPHA";
}
if (args.groupBravo.members.some((m) => m.id === args.userId)) {
return "BRAVO";
}
return null;
}

View File

@ -33,8 +33,6 @@ import {
import type {
SQGroup,
SQGroupMember,
SQMatchGroup,
SQMatchGroupMember,
SQOwnGroup,
} from "../core/SendouQ.server";
import {
@ -62,7 +60,7 @@ export function GroupCard({
showNote = false,
ownGroup,
}: {
group: SQGroup | SQOwnGroup | SQMatchGroup;
group: SQGroup | SQOwnGroup;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE";
displayOnly?: boolean;
hideVc?: SqlBool;
@ -90,16 +88,12 @@ export function GroupCard({
const enableKicking = group.usersRole === "OWNER" && !displayOnly;
// broke after Remix single fetch future flag got toggled on, not sure why this is needed
const members: Array<SQGroupMember | SQMatchGroupMember> | undefined =
group.members;
return (
<GroupCardContainer groupId={group.id} isOwnGroup={isOwnGroup}>
<section className={styles.group} data-testid="sendouq-group-card">
{members ? (
{group.members ? (
<div className="stack md">
{members.map((member) => {
{group.members.map((member) => {
return (
<GroupMember
member={member}
@ -273,7 +267,7 @@ function GroupMember({
showAddNote,
showNote,
}: {
member: SQGroupMember | SQMatchGroupMember;
member: SQGroupMember;
showActions: boolean;
displayOnly?: boolean;
hideVc?: SqlBool;
@ -775,7 +769,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
function VoiceChatInfo({
member,
}: {
member: Pick<SQMatchGroupMember, "id" | "vc" | "languages">;
member: Pick<SQGroupMember, "id" | "vc" | "languages">;
}) {
const user = useUser();
const { t } = useTranslation(["q"]);

View File

@ -7,6 +7,7 @@ import { defaultOrdinal } from "~/features/mmr/mmr-utils";
import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server";
import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as SendouQMatch from "~/features/sendouq-match/core/SendouQMatch";
import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists/types";
@ -43,7 +44,6 @@ export type SQOwnGroup = SerializeFrom<
export type SQMatch = SerializeFrom<ReturnType<SendouQClass["mapMatch"]>>;
export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"];
export type SQGroupMember = NonNullable<SQGroup["members"]>[number];
export type SQMatchGroupMember = SQMatchGroup["members"][number];
const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
const SECONDS_TILL_STALE =
@ -155,7 +155,6 @@ class SendouQClass {
return this.groups.find((group) => group.inviteCode === inviteCode);
}
// xxx: only needed stuff here
/**
* Maps a database match to a format with appropriate censoring based on user permissions.
* Includes private notes for team members and censors sensitive data for non-participants.
@ -169,14 +168,14 @@ class SendouQClass {
/** Array of private user notes to include */
notes: DBPrivateNoteRow[] = [],
) {
const isTeamAlphaMember = match.groupAlpha.members.some(
(m) => m.id === user?.id,
);
const isTeamBravoMember = match.groupBravo.members.some(
(m) => m.id === user?.id,
);
const isMatchInsider =
isTeamAlphaMember || isTeamBravoMember || user?.roles.includes("STAFF");
const viewerSide = SendouQMatch.resolveGroupMemberOf({
groupAlpha: match.groupAlpha,
groupBravo: match.groupBravo,
userId: user?.id,
});
const isTeamAlphaMember = viewerSide === "ALPHA";
const isTeamBravoMember = viewerSide === "BRAVO";
const isMatchInsider = viewerSide !== null || user?.roles.includes("STAFF");
const happenedInLastMonth = isWithinInterval(
databaseTimestampToDate(match.createdAt),
{
@ -191,14 +190,10 @@ class SendouQClass {
) => {
return {
...group,
isReplay: false,
tierRange: null as TierRange | null,
chatCode: isTeamMember ? group.chatCode : undefined,
noScreen: this.#groupNoScreen(group),
tier: match.memento?.groups[group.id]?.tier,
skillDifference: match.memento?.groups[group.id]?.skillDifference,
modePreferences: this.#groupModePreferences(group),
usersRole: null as Tables["GroupMember"]["role"] | null,
matchmade: Boolean(group.matchmade),
members: group.members.map((member) => {
return {
@ -207,7 +202,6 @@ class SendouQClass {
privateNote: null as DBPrivateNoteRow | null,
skillDifference: match.memento?.users[member.id]?.skillDifference,
noScreen: undefined,
languages: member.languages?.split(",") || [],
isContinuing:
typeof member.isContinuing === "number"
? Boolean(member.isContinuing)

View File

@ -1,156 +0,0 @@
import { Lock, LockOpen } from "lucide-react";
import type { JSX } from "react";
import { useFetcher } from "react-router";
import { InfoPopover } from "~/components/InfoPopover";
import { SubmitButton } from "~/components/SubmitButton";
import { TournamentMatchStatus } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import styles from "../tournament-bracket.module.css";
const lockingInfo =
"You can lock the match to indicate that it should not be started before the cast is ready. Match being locked prevents score reporting and hides the map list till the organizer/streamer unlocks it.";
const setAsCastedInfo =
"Select the Twitch account that is currently casting this match. It is then indicated in the bracket view.";
export function CastInfo({
matchIsOngoing,
matchId,
matchIsOver,
matchStatus,
}: {
matchIsOngoing: boolean;
matchId: number;
matchIsOver: boolean;
matchStatus: number;
}) {
const user = useUser();
const tournament = useTournament();
const castedMatchesInfo = tournament.ctx.castedMatchesInfo;
const castTwitchAccounts = tournament.ctx.castTwitchAccounts ?? [];
const currentlyCastedOn = castedMatchesInfo?.castedMatches.find(
(cm) => cm.matchId === matchId,
)?.twitchAccount;
const isLocked = castedMatchesInfo?.lockedMatches?.some(
(lm) => lm.matchId === matchId,
);
const hasPerms = tournament.isOrganizerOrStreamer(user);
if (castTwitchAccounts.length === 0 || !hasPerms || matchIsOver) return null;
// match can only be locked when status is Locked or Waiting (team(s) busy with previous match)
if (
(matchStatus === TournamentMatchStatus.Locked ||
matchStatus === TournamentMatchStatus.Waiting) &&
!isLocked
) {
return (
<CastInfoWrapper
submitButtonText="Lock to be casted"
_action="LOCK"
icon={<Lock />}
infoText={lockingInfo}
>
{castTwitchAccounts.length > 1 ? (
<select
name="twitchAccount"
id="twitchAccount"
aria-label="Twitch account"
>
{castTwitchAccounts.map((account) => (
<option key={account} value={account}>
{account}
</option>
))}
</select>
) : (
<input
type="hidden"
name="twitchAccount"
value={castTwitchAccounts[0]}
/>
)}
</CastInfoWrapper>
);
}
// if for some reason match is locked in the DB but also has scores reported then the UI
// will act as if it's not locked at all
if (!matchIsOngoing && isLocked) {
return (
<CastInfoWrapper
submitButtonText="Unlock"
_action="UNLOCK"
icon={<LockOpen />}
infoText={lockingInfo}
/>
);
}
return (
<CastInfoWrapper
submitButtonText="Save"
_action="SET_AS_CASTED"
infoText={setAsCastedInfo}
>
<select
name="twitchAccount"
id="twitchAccount"
aria-label="Twitch account"
defaultValue={currentlyCastedOn ?? "null"}
data-testid="cast-info-select"
>
<option value="null">Not casted</option>
{castTwitchAccounts.map((account) => (
<option key={account} value={account}>
{account}
</option>
))}
</select>
</CastInfoWrapper>
);
}
function CastInfoWrapper({
children,
icon,
submitButtonText,
_action,
infoText,
}: {
children?: React.ReactNode;
icon?: JSX.Element;
submitButtonText?: string;
_action?: string;
infoText?: string;
}) {
const fetcher = useFetcher();
return (
<div className="stack horizontal sm justify-center items-center">
<fetcher.Form className={styles.castInfoContainer} method="post">
<div className={styles.castInfoContainerLabel}>Cast</div>
<div className="stack horizontal sm items-center justify-between w-full">
{children ? (
<div className={styles.castInfoContainerContent}>{children}</div>
) : null}
{submitButtonText && _action ? (
<SubmitButton
className="mr-2"
state={fetcher.state}
_action={_action}
icon={icon}
testId="cast-info-submit-button"
>
{submitButtonText}
</SubmitButton>
) : null}
</div>
</fetcher.Form>
{infoText ? <InfoPopover>{infoText}</InfoPopover> : null}
</div>
);
}

View File

@ -1,68 +0,0 @@
import clsx from "clsx";
import { differenceInSeconds } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { InfoPopover } from "~/components/InfoPopover";
import * as Deadline from "../core/Deadline";
import styles from "../tournament-bracket.module.css";
interface DeadlineInfoPopoverProps {
startedAt: Date;
bestOf: number;
gamesCompleted: number;
}
export function DeadlineInfoPopover({
startedAt,
bestOf,
gamesCompleted,
}: DeadlineInfoPopoverProps) {
const { t } = useTranslation(["tournament"]);
const [currentTime, setCurrentTime] = React.useState(new Date());
React.useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 5_000);
return () => clearInterval(interval);
}, []);
const elapsedMinutes = differenceInSeconds(currentTime, startedAt) / 60;
const status = Deadline.matchStatus({
elapsedMinutes,
gamesCompleted,
maxGamesCount: bestOf,
});
const warningIndicator =
status === "warning" ? (
<span
className={clsx(
styles.deadlineIndicator,
styles.deadlineIndicatorWarning,
)}
>
!
</span>
) : status === "error" ? (
<span
className={clsx(
styles.deadlineIndicator,
styles.deadlineIndicatorError,
)}
>
!
</span>
) : null;
return (
<div className={styles.deadlinePopover}>
<InfoPopover tiny className={styles.deadlinePopoverTrigger}>
{t("tournament:match.deadline.explanation")}
</InfoPopover>
{warningIndicator}
</div>
);
}

View File

@ -1,527 +0,0 @@
import clsx from "clsx";
import * as React from "react";
import { Link, useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import type { Result } from "~/features/tournament-match/components/StartedMatch";
import type { loader as tournamentMatchLoader } from "~/features/tournament-match/loaders/to.$id.matches.$mid.server";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import { tournamentTeamPage, userPage } from "~/utils/urls";
import { useTournament } from "../../tournament/routes/to.$id";
import type { TournamentDataTeam } from "../core/Tournament.server";
import styles from "../tournament-bracket.module.css";
import { tournamentTeamToActiveRosterUserIds } from "../tournament-bracket-utils";
/** Inputs to select who played for teams in a match as well as the winner. Can also be used in a presentational way. */
export function TeamRosterInputs({
teams,
winnerId,
setWinnerId,
checkedPlayers,
setCheckedPlayers,
points: _points,
setPoints,
result,
revising,
}: {
teams: [TournamentDataTeam, TournamentDataTeam];
winnerId?: number | null;
checkedPlayers: [number[], number[]];
setCheckedPlayers?: React.Dispatch<
React.SetStateAction<[number[], number[]]>
>;
points?: [number, number];
setWinnerId: (newId?: number) => void;
setPoints: React.Dispatch<React.SetStateAction<[number, number]>>;
result?: Result;
revising?: boolean;
}) {
const tournament = useTournament();
const presentational = !revising && Boolean(result);
const points =
typeof result?.opponentOnePoints === "number" &&
typeof result?.opponentTwoPoints === "number" &&
!revising
? ([result.opponentOnePoints, result.opponentTwoPoints] as [
number,
number,
])
: _points;
return (
<div className={styles.duringMatchActionsRosters}>
{teams.map((team, teamI) => {
const winnerRadioChecked = result
? result.winnerTeamId === team.id
: winnerId === team.id;
return (
<TeamRoster
key={team.id}
idx={teamI}
setPoints={setPoints}
presentational={presentational}
team={team}
bothTeamsHaveActiveRosters={teams.every((team) =>
tournamentTeamToActiveRosterUserIds(
team,
tournament.minMembersPerTeam,
),
)}
setWinnerId={setWinnerId}
setCheckedPlayers={setCheckedPlayers}
checkedPlayers={checkedPlayers[teamI].join(",")}
winnerRadioChecked={winnerRadioChecked}
points={points ? points[teamI] : undefined}
result={result}
revising={revising}
/>
);
})}
</div>
);
}
function TeamRoster({
team,
bothTeamsHaveActiveRosters,
presentational,
idx,
setWinnerId,
setPoints,
setCheckedPlayers,
points,
winnerRadioChecked,
checkedPlayers,
result,
revising,
}: {
team: TournamentDataTeam;
bothTeamsHaveActiveRosters: boolean;
presentational: boolean;
idx: number;
setWinnerId: (newId?: number) => void;
setPoints: React.Dispatch<React.SetStateAction<[number, number]>>;
setCheckedPlayers?: React.Dispatch<
React.SetStateAction<[number[], number[]]>
>;
points?: number;
winnerRadioChecked: boolean;
checkedPlayers: string;
result?: Result;
revising?: boolean;
}) {
const tournament = useTournament();
const activeRoster = tournamentTeamToActiveRosterUserIds(
team,
tournament.minMembersPerTeam,
);
const user = useUser();
const canEditRoster =
(team.members.some((member) => member.userId === user?.id) ||
tournament.isOrganizer(user)) &&
!presentational &&
team.members.length > tournament.minMembersPerTeam;
const [_editingRoster, _setEditingRoster] = React.useState(
!activeRoster && canEditRoster,
);
const editingRoster = revising || _editingRoster;
const setEditingRoster = (editing: boolean) => {
const didCancel = !editing;
if (didCancel) {
setCheckedPlayers?.((oldPlayers) => {
const newPlayers = structuredClone(oldPlayers);
newPlayers[idx] = activeRoster ?? [];
return newPlayers;
});
}
_setEditingRoster(editing);
};
const hasPoints = typeof points === "number";
// just so we can center the points nicely
const showWinnerRadio = !hasPoints || !presentational || winnerRadioChecked;
const onPointsChange = React.useCallback(
(newPoint: number) => {
setPoints((points) => {
const newPoints = structuredClone(points);
newPoints[idx] = newPoint;
return newPoints;
});
},
[idx, setPoints],
);
const checkedInputPlayerIds = () => {
if (result?.participants && !revising) {
return result.participants
.filter(
(participant) =>
!participant.tournamentTeamId ||
participant.tournamentTeamId === team.id,
)
.map((participant) => participant.userId);
}
if (editingRoster) return checkedPlayers.split(",").map(Number);
return activeRoster ?? [];
};
const checkedPlayersArray = checkedPlayers.split(",").map(Number);
return (
<div key={team.id}>
<TeamRosterHeader
idx={idx}
team={team}
tournamentId={tournament.ctx.id}
/>
<div className="stack horizontal md justify-center mt-1">
{showWinnerRadio ? (
<WinnerRadio
presentational={presentational || Boolean(revising)}
checked={winnerRadioChecked}
teamId={team.id}
onChange={() => setWinnerId?.(team.id)}
team={idx + 1}
invisible={!bothTeamsHaveActiveRosters}
/>
) : null}
{hasPoints ? (
<PointInput
value={points}
onChange={onPointsChange}
presentational={presentational}
disabled={!bothTeamsHaveActiveRosters}
testId={`points-input-${idx + 1}`}
/>
) : null}
</div>
<TeamRosterInputsCheckboxes
teamId={team.id}
checkedPlayers={checkedInputPlayerIds()}
presentational={!revising && (presentational || !editingRoster)}
handlePlayerClick={(playerId) => {
if (!setCheckedPlayers) return;
setCheckedPlayers((oldPlayers) => {
const newPlayers = structuredClone(oldPlayers);
if (oldPlayers[idx].includes(playerId)) {
newPlayers[idx] = newPlayers[idx].filter((id) => id !== playerId);
} else {
newPlayers[idx].push(playerId);
}
return newPlayers;
});
}}
/>
{!revising && canEditRoster ? (
<RosterFormWithButtons
idx={idx}
editingRoster={editingRoster}
setEditingRoster={setEditingRoster}
showCancelButton={Boolean(activeRoster)}
checkedPlayers={checkedPlayersArray}
teamId={team.id}
valid={checkedPlayersArray.length === tournament.minMembersPerTeam}
/>
) : null}
</div>
);
}
function TeamRosterHeader({
idx,
team,
tournamentId,
}: {
idx: number;
team: TournamentDataTeam;
tournamentId: number;
}) {
return (
<>
<div className="text-xs text-lighter font-semi-bold stack horizontal xs items-center justify-center">
<div className={idx === 0 ? styles.teamOneDot : styles.teamTwoDot} />
Team {idx + 1}
</div>
<h4>
{team.seed ? (
<span className={styles.duringMatchActionsSeed}>#{team.seed}</span>
) : null}{" "}
<Link
to={tournamentTeamPage({
tournamentId,
tournamentTeamId: team.id,
})}
className={styles.duringMatchActionsTeamName}
>
{team.name}
</Link>
</h4>
</>
);
}
/** Renders radio button to select the winner, or in presentational mode just displays the text "Winner" */
function WinnerRadio({
presentational,
teamId,
checked,
onChange,
team,
invisible,
}: {
presentational: boolean;
teamId: number;
checked: boolean;
onChange: () => void;
team: number;
invisible: boolean;
}) {
const id = React.useId();
if (presentational) {
return (
<div
className={clsx("text-xs font-bold stack justify-center", {
invisible: !checked,
"text-theme": team === 1,
"text-theme-secondary": team === 2,
})}
>
Winner
</div>
);
}
return (
<div
className={clsx(styles.duringMatchActionsRadioContainer, {
invisible,
})}
>
<input
type="radio"
id={`${teamId}-${id}`}
onChange={onChange}
checked={checked}
data-testid={`winner-radio-${team}`}
/>
<Label className="mb-0 ml-2" htmlFor={`${teamId}-${id}`}>
Winner
</Label>
</div>
);
}
function PointInput({
value,
onChange,
presentational,
disabled,
testId,
}: {
value: number;
onChange: (newPoint: number) => void;
presentational: boolean;
disabled: boolean;
testId?: string;
}) {
const [focused, setFocused] = React.useState(false);
const id = React.useId();
if (presentational) {
return (
<div className="text-xs text-lighter">
{value === 100 ? "KO" : <>{value}p</>}
</div>
);
}
return (
<div className="stack horizontal sm items-center">
<input
className={styles.pointsInput}
onChange={(e) => onChange(Number(e.target.value))}
type="number"
min={0}
max={100}
disabled={disabled}
value={focused && !value ? "" : String(value)}
required
id={id}
data-testid={testId}
pattern="[0-9]*"
inputMode="numeric"
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
<Label
htmlFor={id}
spaced={false}
className={clsx({ "text-lighter": disabled })}
>
Score
</Label>
</div>
);
}
function TeamRosterInputsCheckboxes({
teamId,
checkedPlayers,
handlePlayerClick,
presentational,
}: {
teamId: number;
checkedPlayers: number[];
handlePlayerClick: (playerId: number) => void;
presentational: boolean;
}) {
const data = useLoaderData<typeof tournamentMatchLoader>();
const id = React.useId();
const tournament = useTournament();
const members = data.match.players.filter(
(p) => p.tournamentTeamId === teamId,
);
const mode = () => {
if (presentational) return "PRESENTATIONAL";
// Disabled in this case because we expect a result to have exactly
// TOURNAMENT_TEAM_ROSTER_MIN_SIZE members per team when reporting it
// so there is no point to let user to change them around
if (members.length <= tournament.minMembersPerTeam) {
return "DISABLED";
}
return "DEFAULT";
};
return (
<div className={styles.duringMatchActionsTeamPlayers}>
{members.map((member, i) => {
return (
<div className="stack horizontal xs" key={member.id}>
<div
className={clsx(
styles.duringMatchActionsCheckboxName,
{ "disabled-opaque": mode() === "DISABLED" },
{ presentational: mode() === "PRESENTATIONAL" },
)}
>
<input
className={clsx(styles.duringMatchActionsCheckbox, {
opaque: presentational,
})}
type="checkbox"
id={`${member.id}-${id}`}
name="playerName"
disabled={mode() === "DISABLED" || mode() === "PRESENTATIONAL"}
value={member.id}
checked={checkedPlayers.includes(member.id)}
onChange={() => handlePlayerClick(member.id)}
data-testid={`player-checkbox-${i}`}
/>{" "}
<label
className={styles.duringMatchActionsPlayerName}
htmlFor={`${member.id}-${id}`}
>
<span className={styles.duringMatchActionsPlayerNameInner}>
{member.inGameName
? inGameNameWithoutDiscriminator(member.inGameName)
: member.username}
</span>
</label>
</div>
<Link to={userPage(member)}>
<Avatar size="xxs" user={member} />
</Link>
</div>
);
})}
</div>
);
}
function RosterFormWithButtons({
idx,
editingRoster,
setEditingRoster,
showCancelButton,
checkedPlayers,
teamId,
valid,
}: {
idx: number;
editingRoster: boolean;
setEditingRoster: (editing: boolean) => void;
showCancelButton?: boolean;
checkedPlayers: number[];
teamId: number;
valid: boolean;
}) {
const fetcher = useFetcher();
if (!editingRoster) {
return (
<div className={styles.rosterButtonsContainer}>
<SendouButton
size="small"
onPress={() => setEditingRoster(true)}
className={styles.editRosterButton}
variant="minimal"
data-testid="edit-active-roster-button"
>
Edit active roster
</SendouButton>
</div>
);
}
return (
<fetcher.Form method="post" className={styles.rosterButtonsContainer}>
<input
type="hidden"
name="roster"
value={JSON.stringify(checkedPlayers)}
/>
<input type="hidden" name="teamId" value={teamId} />
<SubmitButton
state={fetcher.state}
size="small"
_action="SET_ACTIVE_ROSTER"
isDisabled={!valid}
testId={`save-active-roster-button-${idx}`}
>
Save
</SubmitButton>
{showCancelButton ? (
<SendouButton
size="small"
variant="destructive"
onPress={() => {
setEditingRoster(false);
}}
>
Cancel
</SendouButton>
) : null}
</fetcher.Form>
);
}

View File

@ -716,9 +716,11 @@ export const action: ActionFunction = async ({ params, request }) => {
manager.update.match({
id: match.id,
opponent1: {
score: match.opponentOne?.score,
result: winnerTeamId === match.opponentOne!.id ? "win" : "loss",
},
opponent2: {
score: match.opponentTwo?.score,
result: winnerTeamId === match.opponentTwo!.id ? "win" : "loss",
},
});

View File

@ -1,416 +0,0 @@
import { SquarePen } from "lucide-react";
import * as React from "react";
import { Form, useFetcher, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils";
import { TeamRosterInputs } from "~/features/tournament-bracket/components/TeamRosterInputs";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import styles from "~/features/tournament-bracket/tournament-bracket.module.css";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
import invariant from "~/utils/invariant";
import type { loader } from "../loaders/to.$id.matches.$mid.server";
import { isSetOverByScore, matchIsLocked } from "../tournament-match-utils";
import { MatchActionsBanPicker } from "./MatchActionsBanPicker";
import type { Result } from "./StartedMatch";
export function MatchActions({
teams,
position,
result,
scores,
presentational: _presentational,
}: {
teams: [TournamentDataTeam, TournamentDataTeam];
position: number;
result?: Result;
scores: [number, number];
presentational?: boolean;
}) {
const user = useUser();
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
const [checkedPlayers, setCheckedPlayers] = React.useState<
[number[], number[]]
>(() => {
if (result) {
return [
result.participants
.filter((participant) =>
teams[0].members.some(
(member) =>
member.userId === participant.userId &&
(!participant.tournamentTeamId ||
teams[0].id === participant.tournamentTeamId),
),
)
.map((p) => p.userId),
result.participants
.filter((participant) =>
teams[1].members.some(
(member) =>
member.userId === participant.userId &&
(!participant.tournamentTeamId ||
teams[1].id === participant.tournamentTeamId),
),
)
.map((p) => p.userId),
];
}
return [
tournamentTeamToActiveRosterUserIds(
teams[0],
tournament.minMembersPerTeam,
) ?? [],
tournamentTeamToActiveRosterUserIds(
teams[1],
tournament.minMembersPerTeam,
) ?? [],
];
});
const [winnerId, setWinnerId] = React.useState<number | undefined>();
const [points, setPoints] = React.useState<[number, number]>(
typeof result?.opponentOnePoints === "number" &&
typeof result.opponentTwoPoints === "number"
? [result.opponentOnePoints, result.opponentTwoPoints]
: [0, 0],
);
const [revising, setRevising] = React.useState(false);
const presentational = !revising && (_presentational || Boolean(result));
const newScore: [number, number] = [
scores[0] + (winnerId === teams[0].id ? 1 : 0),
scores[1] + (winnerId === teams[1].id ? 1 : 0),
];
const wouldEndSet = isSetOverByScore({
count: data.match.roundMaps.count,
countType: data.match.roundMaps?.type ?? "BEST_OF",
scores: newScore,
});
const showPoints = React.useMemo(
() =>
tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectResultsWithPoints,
[tournament, data.match.id],
);
const bothTeamsHaveActiveRosters = teams.every((team) =>
tournamentTeamToActiveRosterUserIds(team, tournament.minMembersPerTeam),
);
const turnOf =
data.match.roundMaps &&
PickBan.turnOf({
results: data.results,
maps: data.match.roundMaps,
teams: [
{ id: teams[0].id, seed: tournament.teamById(teams[0].id)!.seed },
{ id: teams[1].id, seed: tournament.teamById(teams[1].id)!.seed },
],
mapList: data.mapList,
pickBanEventCount: data.pickBanEventCount,
});
if (turnOf && bothTeamsHaveActiveRosters) {
return (
<MatchActionsBanPicker
key={`${turnOf.teamId}-${data.pickBanEventCount}`}
teams={[teams[0], teams[1]]}
/>
);
}
const canEditFinishedSet =
result && tournament.isOrganizer(user) && !tournament.ctx.isFinalized;
return (
<div>
<TeamRosterInputs
teams={teams}
winnerId={winnerId}
setWinnerId={setWinnerId}
checkedPlayers={checkedPlayers}
setCheckedPlayers={setCheckedPlayers}
points={showPoints ? points : undefined}
setPoints={setPoints}
result={result}
revising={revising}
/>
{!presentational && bothTeamsHaveActiveRosters ? (
<Form method="post" className={styles.duringMatchActionsActions}>
<input type="hidden" name="winnerTeamId" value={winnerId ?? ""} />
{showPoints ? (
<input type="hidden" name="points" value={JSON.stringify(points)} />
) : null}
<input type="hidden" name="position" value={position} />
{!revising && (
<ReportScoreButtons
key={scores.join("-")}
winnerIdx={winnerId ? winningTeamIdx() : undefined}
points={showPoints ? points : undefined}
winnerOfSetName={winnerOfSetName()}
wouldEndSet={wouldEndSet}
matchLocked={matchIsLocked({
matchId: data.match.id,
scores: scores,
tournament,
})}
newScore={newScore}
/>
)}
</Form>
) : null}
{canEditFinishedSet ? (
<EditScoreForm
editing={revising}
setEditing={setRevising}
checkedPlayers={checkedPlayers}
resultId={result.id}
points={showPoints ? points : undefined}
submitDisabled={checkedPlayers.some(
(teamMembers) =>
teamMembers.length !== tournament.minMembersPerTeam,
)}
/>
) : null}
{!result && presentational ? (
<div className={styles.duringMatchActionsActions}>
<p className={styles.duringMatchActionsAmountWarningParagraph}>
No permissions to report score
</p>
</div>
) : null}
</div>
);
function winnerOfSetName() {
if (!winnerId) return;
const setWinningIdx = newScore[0] > newScore[1] ? 0 : 1;
const result = teams[setWinningIdx].name;
invariant(result, "No set winning team");
return result;
}
function winningTeamIdx() {
if (!winnerId) return;
if (teams[0].id === winnerId) return 0;
if (teams[1].id === winnerId) return 1;
throw new Error("No winning team matching the id");
}
}
function ReportScoreButtons({
points,
winnerIdx,
winnerOfSetName,
wouldEndSet,
matchLocked,
newScore,
}: {
points?: [number, number];
winnerIdx?: number;
winnerOfSetName?: string;
wouldEndSet: boolean;
matchLocked: boolean;
newScore: [number, number];
}) {
const data = useLoaderData<typeof loader>();
const user = useUser();
const tournament = useTournament();
const confirmCheckId = React.useId();
const pointConfirmCheckId = React.useId();
const [endConfirmation, setEndConfirmation] = React.useState(false);
const [pointConfirmation, setPointConfirmation] = React.useState(false);
if (isLeagueRoundLocked(tournament, data.match.roundId)) {
return (
<p className={styles.duringMatchActionsAmountWarningParagraph}>
League round has not started yet
</p>
);
}
if (matchLocked) {
return (
<p className={styles.duringMatchActionsAmountWarningParagraph}>
Match is pending to be casted. Please wait a bit
</p>
);
}
if (
points &&
typeof winnerIdx === "number" &&
points[winnerIdx] <= points[winnerIdx === 0 ? 1 : 0]
) {
return (
<p className={styles.duringMatchActionsAmountWarningParagraph}>
Winner should have higher score than loser
</p>
);
}
if (
points &&
((points[0] === 100 && points[1] !== 0) ||
(points[0] !== 0 && points[1] === 100))
) {
return (
<p className={styles.duringMatchActionsAmountWarningParagraph}>
If there was a KO (100 score), other team should have 0 score
</p>
);
}
if (typeof winnerIdx !== "number") {
return (
<p className={styles.duringMatchActionsAmountWarningParagraph}>
Please select the winner of this map
</p>
);
}
const confirmationClass = () => {
const ownTeam = tournament.teamMemberOfByUser(user);
// TO reporting
if (!ownTeam) return "text-main-forced";
if (ownTeam.name === winnerOfSetName) return "text-success";
return "text-warning";
};
const lowPoints = points?.every((point) => point < 10);
const submitButtonDisabled = () => {
if (wouldEndSet && !endConfirmation) return true;
if (lowPoints && !pointConfirmation) return true;
return false;
};
return (
<div className="stack md items-center">
{wouldEndSet ? (
<div className="stack horizontal sm items-center">
<input
type="checkbox"
checked={endConfirmation}
onChange={(e) => setEndConfirmation(e.target.checked)}
id={confirmCheckId}
data-testid="end-confirmation"
/>
<Label spaced={false} htmlFor={confirmCheckId}>
<span className="text-main-forced">Set over?</span>{" "}
<span className={confirmationClass()}>
({newScore.join("-")} win for {winnerOfSetName})
</span>
</Label>
</div>
) : null}
{lowPoints ? (
<div className="stack horizontal sm items-center">
<input
type="checkbox"
checked={pointConfirmation}
onChange={(e) => setPointConfirmation(e.target.checked)}
id={pointConfirmCheckId}
/>
<Label spaced={false} htmlFor={pointConfirmCheckId}>
Confirm reporting of low score value (
{points!.map((p) => `${p}p`).join(" & ")})
</Label>
</div>
) : null}
<SubmitButton
size="small"
_action="REPORT_SCORE"
testId="report-score-button"
isDisabled={submitButtonDisabled()}
>
{wouldEndSet ? "Report & end set" : "Report"}
</SubmitButton>
</div>
);
}
function EditScoreForm({
editing,
setEditing,
checkedPlayers,
resultId,
points,
submitDisabled,
}: {
editing: boolean;
setEditing: (value: boolean) => void;
checkedPlayers: [number[], number[]];
resultId: number;
points?: [number, number];
submitDisabled: boolean;
}) {
const fetcher = useFetcher();
if (editing) {
return (
<fetcher.Form
method="post"
className="stack horizontal md justify-center mt-6"
>
<input type="hidden" name="resultId" value={resultId} />
<input
type="hidden"
name="rosters"
value={JSON.stringify(checkedPlayers)}
/>
{points ? (
<input type="hidden" name="points" value={JSON.stringify(points)} />
) : undefined}
<SubmitButton
size="small"
state={fetcher.state}
_action="UPDATE_REPORTED_SCORE"
isDisabled={submitDisabled}
testId="save-revise-button"
>
Save
</SubmitButton>
<SendouButton
variant="destructive"
size="small"
onPress={() => setEditing(false)}
>
Cancel
</SendouButton>
</fetcher.Form>
);
}
return (
<div className="mt-6">
<SendouButton
icon={<SquarePen />}
variant="outlined"
size="small"
className="mx-auto"
onPress={() => setEditing(true)}
data-testid="revise-button"
>
Edit
</SendouButton>
</div>
);
}

View File

@ -1,89 +0,0 @@
.mapPoolPicker {
--map-width: 90px;
--map-height: 50px;
}
.divider {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
display: flex;
gap: var(--s-2);
&::before,
&::after {
border-bottom: 2px dotted var(--color-bg-higher);
}
}
.mapButton {
background-image: var(--map-image-url);
background-size: cover;
height: var(--map-height);
width: var(--map-width);
border: none;
background-color: transparent;
transition:
filter,
opacity 0.2s;
border-radius: var(--radius-box);
&:active {
transform: none;
}
}
.mapButtonGreyedOut {
filter: grayscale(100%) !important;
opacity: 0.4 !important;
}
.mapButtonIcon {
position: absolute;
top: 2px;
color: var(--color-success);
width: 48px;
height: 48px;
cursor: pointer;
}
.mapButtonIconError {
color: var(--color-error);
}
.mapButtonIconMuted {
color: var(--color-text-high);
}
.mapButtonNumber {
position: absolute;
background-color: var(--color-text-accent);
border-radius: 100%;
width: 18px;
height: 18px;
display: grid;
place-items: center;
color: var(--color-text-inverse);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
top: -5px;
left: 0;
}
.mapButtonFrom {
position: absolute;
bottom: -15px;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
.mapButtonContainer {
width: var(--map-width);
text-align: center;
}
.mapButtonLabel {
font-size: var(--font-2xs);
color: var(--color-text-high);
font-weight: var(--weight-semi);
}

View File

@ -1,440 +0,0 @@
import clsx from "clsx";
import { Check, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
import { Divider } from "~/components/Divider";
import { ModeImage, StageImage } from "~/components/Image";
import { SubmitButton } from "~/components/SubmitButton";
import type { ActionType, TournamentRoundMaps } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import { modesShort } from "~/modules/in-game-lists/modes";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { stageImageUrl } from "~/utils/urls";
import type { loader } from "../loaders/to.$id.matches.$mid.server";
import styles from "./MatchActionsBanPicker.module.css";
/** stageId is omitted for mode-only actions (MODE_PICK / MODE_BAN) where no specific stage is selected */
type BanPickerSelection = {
mode: ModeShort;
stageId?: StageId;
};
export function MatchActionsBanPicker({
teams,
}: {
teams: [TournamentDataTeam, TournamentDataTeam];
}) {
const data = useLoaderData<typeof loader>();
const tournament = useTournament();
const maps = data.match.roundMaps!;
const [selected, setSelected] = React.useState<BanPickerSelection>();
const turnOfResult = PickBan.turnOf({
results: data.results,
maps,
teams: [
{ id: teams[0].id, seed: tournament.teamById(teams[0].id)!.seed },
{ id: teams[1].id, seed: tournament.teamById(teams[1].id)!.seed },
],
mapList: data.mapList,
pickBanEventCount: data.pickBanEventCount,
})!;
const pickerTeamId = turnOfResult.teamId;
const pickingTeam = teams.find((team) => team.id === pickerTeamId)!;
const actionType = turnOfResult.action;
const isModePick = actionType === "MODE_PICK";
const isModeBan = actionType === "MODE_BAN";
const isModeAction = isModePick || isModeBan;
return (
<div>
{isModeAction ? (
<ModePicker
selected={selected}
setSelected={setSelected}
pickerTeamId={pickerTeamId}
teams={teams}
/>
) : (
<MapPicker
selected={selected}
setSelected={setSelected}
pickerTeamId={pickerTeamId}
teams={teams}
actionType={actionType}
/>
)}
<CounterpickSubmitter
selected={selected}
pickingTeam={pickingTeam}
pickBan={data.match.roundMaps!.pickBan!}
actionType={actionType}
/>
</div>
);
}
function MapPicker({
selected,
setSelected,
pickerTeamId,
teams,
actionType,
}: {
selected?: BanPickerSelection;
setSelected: (selected: BanPickerSelection) => void;
pickerTeamId: number;
teams: [TournamentDataTeam, TournamentDataTeam];
actionType: ActionType;
}) {
const user = useUser();
const data = useLoaderData<typeof loader>();
const tournament = useTournament();
const pickBanMapPool = PickBan.mapsListWithLegality({
toSetMapPool: tournament.ctx.toSetMapPool,
maps: data.match.roundMaps,
mapList: data.mapList,
teams,
tieBreakerMapPool: tournament.ctx.tieBreakerMapPool,
pickerTeamId,
results: data.results,
pickBanEvents: data.pickBanEvents,
});
const modes = modesShort.filter((mode) =>
pickBanMapPool.some((map) => map.mode === mode && map.isLegal),
);
const canPickBan =
tournament.isOrganizer(user) ||
tournament.ownedTeamByUser(user)?.id === pickerTeamId;
const teamMemberOf = tournament.teamMemberOfByUser(user);
const isPartOfTheMatch = teams.some((t) => t.id === teamMemberOf?.id);
const mapFromWhere = (stageId: StageId, mode: ModeShort) => {
if (!isPartOfTheMatch) {
return;
}
const teamOneHas = teams[0].mapPool?.some(
(map) => map.stageId === stageId && map.mode === mode,
);
const teamTwoHas = teams[1].mapPool?.some(
(map) => map.stageId === stageId && map.mode === mode,
);
if (teamOneHas && teamTwoHas) {
return "BOTH";
}
if (teamOneHas) {
return teams[0].id === teamMemberOf?.id ? "US" : "THEM";
}
if (teamTwoHas) {
return teams[1].id === teamMemberOf?.id ? "US" : "THEM";
}
return;
};
const pickersLastWonMode = data.results
.slice()
.reverse()
.find((result) => result.winnerTeamId === pickerTeamId)?.mode;
return (
<div className="stack lg">
{modes.map((mode) => {
const stages = pickBanMapPool
.filter((map) => map.mode === mode)
.sort((a, b) => a.stageId - b.stageId);
return (
<div key={mode} className={clsx(styles.mapPoolPicker, "stack sm")}>
<Divider className={styles.divider}>
<ModeImage mode={mode} size={32} />
</Divider>
<div
className={clsx(
"stack horizontal flex-wrap justify-center mt-1",
{
"lg-row sm-column": isPartOfTheMatch,
sm: !isPartOfTheMatch,
},
)}
>
{stages.map(({ stageId, isLegal }) => {
const number =
data.match.roundMaps?.pickBan === "BAN_2"
? (data.mapList ?? [])?.findIndex(
(m) => m.stageId === stageId && m.mode === mode,
) + 1
: undefined;
return (
<MapButton
key={stageId}
stageId={stageId}
disabled={!isLegal}
selected={
selected?.mode === mode && selected.stageId === stageId
}
actionType={actionType}
onClick={
canPickBan
? () => setSelected({ mode, stageId })
: undefined
}
number={number}
from={mapFromWhere(stageId, mode)}
/>
);
})}
</div>
{data.match.roundMaps?.pickBan !== "CUSTOM" &&
pickersLastWonMode === mode &&
modes.length > 1 ? (
<div className="text-error text-xs text-center mt-2">
Can&apos;t pick the same mode team last won on
</div>
) : null}
</div>
);
})}
</div>
);
}
function MapButton({
stageId,
onClick,
selected,
disabled,
actionType,
number,
from,
}: {
stageId: StageId;
onClick?: () => void;
selected?: boolean;
disabled?: boolean;
actionType?: ActionType;
number?: number;
from?: "US" | "THEM" | "BOTH";
}) {
const { t } = useTranslation(["game-misc"]);
return (
<div
className={clsx("stack items-center relative", styles.mapButtonContainer)}
>
<button
className={clsx(styles.mapButton, {
[styles.mapButtonGreyedOut]: selected || disabled,
})}
style={{ "--map-image-url": `url("${stageImageUrl(stageId)}.avif")` }}
onClick={onClick}
type="button"
disabled={!onClick}
data-testid={!disabled && onClick ? "pick-ban-button" : undefined}
/>
{selected && !disabled ? (
actionType === "BAN" || actionType === "MODE_BAN" ? (
<X
className={clsx(styles.mapButtonIcon, styles.mapButtonIconMuted)}
onClick={onClick}
/>
) : (
<Check
className={clsx(styles.mapButtonIcon, styles.mapButtonIconMuted)}
onClick={onClick}
/>
)
) : null}
{disabled ? (
<X className={clsx(styles.mapButtonIcon, styles.mapButtonIconError)} />
) : null}
{number ? <span className={styles.mapButtonNumber}>{number}</span> : null}
{from ? (
<span
className={clsx(styles.mapButtonFrom, {
"text-theme": from === "BOTH",
"text-success": from === "US",
"text-error": from === "THEM",
})}
>
{from === "BOTH" ? "Both" : from === "THEM" ? "Them" : "Us"}
</span>
) : null}
<div className={styles.mapButtonLabel}>
{shortStageName(t(`game-misc:STAGE_${stageId}`))}
</div>
</div>
);
}
function ModePicker({
selected,
setSelected,
pickerTeamId,
teams,
}: {
selected?: BanPickerSelection;
setSelected: (selected: BanPickerSelection) => void;
pickerTeamId: number;
teams: [TournamentDataTeam, TournamentDataTeam];
}) {
const user = useUser();
const data = useLoaderData<typeof loader>();
const tournament = useTournament();
const { t } = useTranslation(["game-misc"]);
const pickBanMapPool = PickBan.mapsListWithLegality({
toSetMapPool: tournament.ctx.toSetMapPool,
maps: data.match.roundMaps,
mapList: data.mapList,
teams,
tieBreakerMapPool: tournament.ctx.tieBreakerMapPool,
pickerTeamId,
results: data.results,
pickBanEvents: data.pickBanEvents,
});
const availableModes = modesShort.filter((mode) =>
pickBanMapPool.some((map) => map.mode === mode && map.isLegal),
);
const canPickBan =
tournament.isOrganizer(user) ||
tournament.ownedTeamByUser(user)?.id === pickerTeamId;
return (
<div className="stack horizontal md justify-center flex-wrap">
{availableModes.map((mode) => (
<button
key={mode}
type="button"
className={clsx(styles.mapButton, {
[styles.mapButtonGreyedOut]: selected?.mode === mode,
})}
onClick={canPickBan ? () => setSelected({ mode }) : undefined}
disabled={!canPickBan}
data-testid={canPickBan ? "pick-ban-button" : undefined}
>
<ModeImage mode={mode} size={48} />
<div className={styles.mapButtonLabel}>
{t(`game-misc:MODE_SHORT_${mode}`)}
</div>
{selected?.mode === mode ? (
<Check className={styles.mapButtonIcon} />
) : null}
</button>
))}
</div>
);
}
function CounterpickSubmitter({
selected,
pickingTeam,
pickBan,
actionType,
}: {
selected?: BanPickerSelection;
pickingTeam: TournamentDataTeam;
pickBan: NonNullable<TournamentRoundMaps["pickBan"]>;
actionType: ActionType;
}) {
const fetcher = useFetcher();
const { t } = useTranslation(["game-misc"]);
const user = useUser();
const tournament = useTournament();
const ownedTeam = tournament.ownedTeamByUser(user);
const picking =
tournament.isOrganizer(user) || ownedTeam?.id === pickingTeam.id;
const isModeAction = actionType === "MODE_PICK" || actionType === "MODE_BAN";
const isCustom = pickBan === "CUSTOM";
const actionLabel = () => {
if (actionType === "BAN" || pickBan === "BAN_2") return "Ban";
if (actionType === "MODE_PICK") return "Pick mode";
if (actionType === "MODE_BAN") return "Ban mode";
if (isCustom) return "Pick";
return "Counterpick";
};
const promptLabel = () => {
if (actionType === "BAN" || pickBan === "BAN_2") {
return "Please select your team's ban above";
}
if (actionType === "MODE_PICK") return "Please select a mode to pick above";
if (actionType === "MODE_BAN") return "Please select a mode to ban above";
if (isCustom) return "Please select your team's pick above";
return "Please select your team's counterpick above";
};
if (!picking) {
return (
<div className="mt-6 text-lighter text-sm text-center">
Waiting for captain of {pickingTeam.name} to make their selection
</div>
);
}
if (picking && !selected) {
return (
<div className="mt-6 text-lighter text-sm text-center">
{promptLabel()}
</div>
);
}
invariant(selected, "CounterpickSubmitter: selected is undefined");
const stageId = isModeAction ? null : selected.stageId;
invariant(isModeAction || typeof stageId === "number", "Expected stageId");
return (
<div className="stack md items-center">
<div
className={clsx("mt-6 text-lighter text-sm", {
"text-warning":
actionType === "BAN" ||
actionType === "MODE_BAN" ||
pickBan === "BAN_2",
})}
>
{actionLabel()}: {t(`game-misc:MODE_SHORT_${selected.mode}`)}
{typeof stageId === "number"
? ` ${t(`game-misc:STAGE_${stageId}`)}`
: null}
</div>
<div className="stack sm horizontal">
<ModeImage mode={selected.mode} size={32} />
{typeof stageId === "number" ? (
<StageImage stageId={stageId} height={32} className="rounded-sm" />
) : null}
</div>
<fetcher.Form method="post">
{typeof stageId === "number" ? (
<input type="hidden" name="stageId" value={stageId} />
) : null}
<input type="hidden" name="mode" value={selected.mode} />
<SubmitButton _action="BAN_PICK">Confirm</SubmitButton>
</fetcher.Form>
</div>
);
}

View File

@ -1,166 +0,0 @@
import clsx from "clsx";
import { Link, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { useTournament } from "~/features/tournament/routes/to.$id";
import styles from "~/features/tournament-bracket/tournament-bracket.module.css";
import { tournamentTeamPage, userPage } from "~/utils/urls";
import type { loader } from "../loaders/to.$id.matches.$mid.server";
export function MatchRosters({
teams,
}: {
teams: [id: number | null | undefined, id: number | null | undefined];
}) {
const data = useLoaderData<typeof loader>();
const tournament = useTournament();
const teamOne = teams[0] ? tournament.teamById(teams[0]) : undefined;
const teamTwo = teams[1] ? tournament.teamById(teams[1]) : undefined;
const teamOnePlayers = data.match.players.filter(
(p) => p.tournamentTeamId === teamOne?.id,
);
const teamTwoPlayers = data.match.players.filter(
(p) => p.tournamentTeamId === teamTwo?.id,
);
const teamOneParticipatedPlayers = teamOnePlayers.filter((p) =>
tournament.ctx.participatedUsers.includes(p.id),
);
const teamTwoParticipatedPlayers = teamTwoPlayers.filter((p) =>
tournament.ctx.participatedUsers.includes(p.id),
);
const teamOneLogoSrc = teamOne
? tournament.tournamentTeamLogoSrc(teamOne)
: null;
const teamTwoLogoSrc = teamTwo
? tournament.tournamentTeamLogoSrc(teamTwo)
: null;
return (
<div className={styles.rosters}>
<div className="stack xxs">
<div className="stack xs horizontal items-center text-lighter">
<div className={styles.teamOneDot} />
Team 1
</div>
<h2
className={clsx("text-sm", {
"text-lighter": !teamOne,
[styles.rostersSpacedHeader]: teamOne || teamTwo,
})}
>
{teamOne ? (
<Link
to={tournamentTeamPage({
tournamentId: tournament.ctx.id,
tournamentTeamId: teamOne.id,
})}
className="text-main-forced font-bold stack horizontal xs items-center"
>
<Avatar
url={teamOneLogoSrc}
identiconInput={teamOne.name}
size="sm"
/>
{teamOne.name}
</Link>
) : (
"Waiting on team"
)}
</h2>
{teamOnePlayers.length > 0 ? (
<ul className="stack xs mt-2">
{teamOnePlayers.map((p) => {
const isInactive =
teamOneParticipatedPlayers.length > 0 &&
teamOneParticipatedPlayers.every(
(participatedPlayer) => p.id !== participatedPlayer.id,
);
return (
<li key={p.id}>
<Link
to={userPage(p)}
className={clsx("stack horizontal sm items-center", {
[styles.inactivePlayer]: isInactive,
})}
>
<Avatar user={p} size="xxs" />
<span>{p.username}</span>
{p.pronouns ? (
<span className="text-lighter ml-1 text-xxxs">
{p.pronouns.subject}/{p.pronouns.object}
</span>
) : null}
</Link>
</li>
);
})}
</ul>
) : null}
</div>
<div className="stack xxs">
<div className="stack xs horizontal items-center text-lighter">
<div className={styles.teamTwoDot} />
Team 2
</div>
<h2
className={clsx("text-sm", {
"text-lighter": !teamTwo,
[styles.rostersSpacedHeader]: teamOne || teamTwo,
})}
>
{teamTwo ? (
<Link
to={tournamentTeamPage({
tournamentId: tournament.ctx.id,
tournamentTeamId: teamTwo.id,
})}
className="text-main-forced font-bold stack horizontal xs items-center"
>
<Avatar
url={teamTwoLogoSrc}
identiconInput={teamTwo.name}
size="sm"
/>
{teamTwo.name}
</Link>
) : (
"Waiting on team"
)}
</h2>
{teamTwoPlayers.length > 0 ? (
<ul className="stack xs mt-2">
{teamTwoPlayers.map((p) => {
const isInactive =
teamTwoParticipatedPlayers.length > 0 &&
teamTwoParticipatedPlayers.every(
(participatedPlayer) => p.id !== participatedPlayer.id,
);
return (
<li key={p.id}>
<Link
to={userPage(p)}
className={clsx("stack horizontal sm items-center", {
[styles.inactivePlayer]: isInactive,
})}
>
<Avatar user={p} size="xxs" />
<span>{p.username}</span>
{p.pronouns ? (
<span className="text-lighter ml-1 text-xxxs">
{p.pronouns.subject}/{p.pronouns.object}
</span>
) : null}
</Link>
</li>
);
})}
</ul>
) : null}
</div>
</div>
);
}

View File

@ -1,65 +0,0 @@
.progressContainer {
position: relative;
width: 100%;
height: 18px;
background-color: var(--color-bg);
}
.progressBar {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: var(--color-text-accent);
transition:
width 0.5s ease-in-out,
background-color 0.3s ease;
z-index: 1;
}
.gameMarker {
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--s-1);
z-index: 2;
pointer-events: none;
transform: translateX(-50%);
}
.gameMarkerLine {
width: 2px;
height: 100%;
background-color: var(--color-text);
opacity: 0.6;
}
.maxTimeMarker {
position: absolute;
right: 0;
top: 0;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--s-1);
z-index: 2;
pointer-events: none;
padding-right: var(--s-1);
}
.gameMarkerText {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
white-space: nowrap;
text-shadow: 0 0 3px var(--color-text-inverse);
}
.gameMarkerHidden {
& .gameMarkerText {
visibility: hidden;
}
}

View File

@ -1,75 +0,0 @@
import { clsx } from "clsx";
import { differenceInSeconds } from "date-fns";
import * as React from "react";
import * as Deadline from "~/features/tournament-bracket/core/Deadline";
import styles from "./MatchTimer.module.css";
interface MatchTimerProps {
startedAt: Date;
bestOf: number;
}
export function MatchTimer({ startedAt, bestOf }: MatchTimerProps) {
const [currentTime, setCurrentTime] = React.useState(new Date());
React.useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 5_000);
return () => clearInterval(interval);
}, []);
const elapsedMinutes = differenceInSeconds(currentTime, startedAt) / 60;
const totalMinutes = Deadline.totalMatchTime(bestOf);
const progressPercentage = Deadline.progressPercentage(
elapsedMinutes,
totalMinutes,
);
const gameMarkers = Deadline.gameMarkers(bestOf);
return (
<div data-testid="match-timer">
<div className={styles.progressContainer}>
<div
className={styles.progressBar}
style={{
width: `${Math.min(progressPercentage, 100)}%`,
}}
/>
{gameMarkers.map((marker) => (
<div
key={marker.gameNumber}
className={clsx(styles.gameMarker, {
[styles.gameMarkerHidden]: marker.gameNumber !== 1,
})}
style={{ left: `${marker.percentage}%` }}
>
<div
className={clsx(styles.gameMarkerText, styles.gameMarkerLabel)}
>
G{marker.gameNumber}
</div>
<div className={styles.gameMarkerLine} />
<div
className={clsx(styles.gameMarkerText, styles.gameMarkerLabel)}
>
Start
</div>
</div>
))}
<div className={styles.maxTimeMarker}>
<div className={clsx(styles.gameMarkerText, styles.gameMarkerTime)}>
Max
</div>
<div className={clsx(styles.gameMarkerText, styles.gameMarkerTime)}>
{totalMinutes}min
</div>
</div>
</div>
</div>
);
}

View File

@ -1,872 +0,0 @@
import clsx from "clsx";
import { differenceInMinutes } from "date-fns";
import type { TFunction } from "i18next";
import { Check, MousePointerClick, X } from "lucide-react";
import type { JSX } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Form, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { Image } from "~/components/Image";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import {
isLeagueRoundLocked,
resolveLeagueRoundStartDate,
} from "~/features/tournament/tournament-utils";
import { DeadlineInfoPopover } from "~/features/tournament-bracket/components/DeadlineInfoPopover";
import type { Bracket } from "~/features/tournament-bracket/core/Bracket";
import * as Deadline from "~/features/tournament-bracket/core/Deadline";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import styles from "~/features/tournament-bracket/tournament-bracket.module.css";
import {
groupNumberToLetters,
tournamentTeamToActiveRosterUserIds,
} from "~/features/tournament-bracket/tournament-bracket-utils";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { StageId } from "~/modules/in-game-lists/types";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
import { nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import type { Unpacked } from "~/utils/types";
import {
modeImageUrl,
specialWeaponImageUrl,
stageImageUrl,
} from "~/utils/urls";
import type {
loader,
TournamentMatchLoaderData,
} from "../loaders/to.$id.matches.$mid.server";
import {
mapCountPlayedInSetWithCertainty,
matchIsLocked,
pickInfoText,
resolveHostingTeam,
resolveRoomPass,
} from "../tournament-match-utils";
import { MatchActions } from "./MatchActions";
import { MatchRosters } from "./MatchRosters";
import { MatchTimer } from "./MatchTimer";
export type Result = Unpacked<TournamentMatchLoaderData["results"]>;
export function StartedMatch({
teams,
currentStageWithMode,
selectedResultIndex,
setSelectedResultIndex,
result,
type,
}: {
teams: [TournamentDataTeam, TournamentDataTeam];
result?: Result;
currentStageWithMode?: TournamentMapListMap;
selectedResultIndex?: number;
// if this is set it means the component is being used in presentation manner
setSelectedResultIndex?: (index: number) => void;
type: "EDIT" | "OTHER";
}) {
const { t } = useTranslation(["tournament"]);
const isHydrated = useHydrated();
const user = useUser();
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
const scoreOne = data.match.opponentOne?.score ?? 0;
const scoreTwo = data.match.opponentTwo?.score ?? 0;
const currentPosition = scoreOne + scoreTwo;
const presentational = Boolean(setSelectedResultIndex);
const showFullInfos = !presentational && type === "EDIT";
const isMemberOfTeamParticipating = data.match.players.some(
(p) => p.id === user?.id,
);
const waitingForPreviousMatch = data.match.status === 0;
const hostingTeamId = resolveHostingTeam(teams).id;
const poolCode = React.useMemo(() => {
const match = tournament.brackets
.flatMap((b) => b.data.match)
.find((m) => m.id === data.match.id);
const hasRoundRobin = tournament.brackets.some(
(b) => b.type === "round_robin",
);
const bracketIdx = tournament.brackets.findIndex((b) =>
b.data.match.some((m) => m.id === data.match.id),
);
const bracket = tournament.brackets[bracketIdx] as Bracket | undefined;
const group = tournament.brackets
.flatMap((b) => b.data.group)
.find((group) => group.id === match?.group_id);
return tournament.resolvePoolCode({
hostingTeamId,
groupLetters:
group && bracket?.type === "round_robin"
? groupNumberToLetters(group.number)
: undefined,
bracketNumber:
hasRoundRobin && bracket?.type !== "round_robin"
? bracketIdx + 1
: undefined,
});
}, [tournament, hostingTeamId, data.match.id]);
const roundInfos = [
showFullInfos ? (
<React.Fragment key="hosts">
{t("tournament:match.hosts", {
teamName: resolveHostingTeam(teams).name,
})}
</React.Fragment>
) : null,
showFullInfos ? (
<React.Fragment key="pass">
{t("tournament:match.pass")}{" "}
<span className="text-theme font-bold" data-testid="room-pass">
{resolveRoomPass(hostingTeamId)}
</span>
</React.Fragment>
) : null,
showFullInfos ? (
<span key="pool">
{t("tournament:match.pool")} {poolCode.prefix}
<span className="text-theme font-bold">{poolCode.suffix}</span>
</span>
) : null,
<React.Fragment key="score">
{data.match.roundMaps?.type === "PLAY_ALL"
? t("tournament:match.score.playAll", {
scoreOne,
scoreTwo,
bestOf: data.match.bestOf,
})
: t("tournament:match.score", {
scoreOne,
scoreTwo,
bestOf: data.match.bestOf,
})}
</React.Fragment>,
tournament.ctx.settings.enableNoScreenToggle &&
typeof data.noScreen === "boolean" ? (
<ScreenBanIcons key="screen-ban" banned={data.noScreen} />
) : null,
];
return (
<div className={styles.duringMatchActions}>
<FancyStageBanner
stage={currentStageWithMode}
infos={roundInfos}
teams={teams}
matchIsLocked={matchIsLocked({
matchId: data.match.id,
scores: [scoreOne, scoreTwo],
tournament,
})}
waitingForPreviousMatch={waitingForPreviousMatch}
>
{currentPosition > 0 &&
!presentational &&
type === "EDIT" &&
(tournament.isOrganizer(user) || isMemberOfTeamParticipating) && (
<Form method="post">
<input
type="hidden"
name="position"
value={currentPosition - 1}
/>
<div className={styles.stageBannerBottomBar}>
<SubmitButton
_action="UNDO_REPORT_SCORE"
className={styles.stageBannerUndoButton}
variant="destructive"
size="miniscule"
testId="undo-score-button"
>
{t("tournament:match.action.undoLastScore")}
</SubmitButton>
</div>
</Form>
)}
{tournament.isOrganizer(user) &&
tournament.matchCanBeReopened(data.match.id) &&
presentational && (
<Form method="post">
<div className={styles.stageBannerBottomBar}>
<SubmitButton
_action="REOPEN_MATCH"
className={styles.stageBannerUndoButton}
variant="destructive"
size="miniscule"
testId="reopen-match-button"
>
{t("tournament:match.action.reopenMatch")}
</SubmitButton>
</div>
</Form>
)}
{tournament.isOrganizer(user) &&
!data.matchIsOver &&
data.match.startedAt &&
Deadline.matchStatus({
elapsedMinutes: differenceInMinutes(
new Date(),
databaseTimestampToDate(data.match.startedAt),
),
gamesCompleted: scoreOne + scoreTwo,
maxGamesCount: data.match.bestOf,
}) === "error" ? (
<EndSetPopover teams={teams} />
) : null}
</FancyStageBanner>
<ModeProgressIndicator
scores={[scoreOne, scoreTwo]}
bestOf={data.match.bestOf}
selectedResultIndex={selectedResultIndex}
setSelectedResultIndex={setSelectedResultIndex}
/>
{!waitingForPreviousMatch && (type === "EDIT" || presentational) ? (
<StartedMatchTabs
presentational={presentational}
scores={[scoreOne, scoreTwo]}
teams={teams}
result={result}
/>
) : null}
{result ? (
<div
className={clsx("text-center text-xs text-lighter", {
invisible: !isHydrated,
})}
data-testid="report-timestamp"
>
{isHydrated
? databaseTimestampToDate(result.createdAt).toLocaleString()
: "t"}
</div>
) : null}
</div>
);
}
function FancyStageBanner({
stage,
infos,
children,
teams,
matchIsLocked,
waitingForPreviousMatch,
}: {
stage?: TournamentMapListMap;
infos?: (JSX.Element | null)[];
children?: React.ReactNode;
teams: [TournamentDataTeam, TournamentDataTeam];
matchIsLocked: boolean;
waitingForPreviousMatch: boolean;
}) {
const user = useUser();
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["game-misc", "tournament"]);
const tournament = useTournament();
const gamesCompleted = data.results.length;
const stageNameToBannerImageUrl = (stageId: StageId) => {
return `${stageImageUrl(stageId)}.avif`;
};
const turnOfResult = (() => {
if (
!data.match.roundMaps ||
!data.match.opponentOne?.id ||
!data.match.opponentTwo?.id
) {
return null;
}
return PickBan.turnOf({
results: data.results,
maps: data.match.roundMaps,
teams: [
{
id: data.match.opponentOne.id,
seed: tournament.teamById(data.match.opponentOne.id)!.seed,
},
{
id: data.match.opponentTwo.id,
seed: tournament.teamById(data.match.opponentTwo.id)!.seed,
},
],
mapList: data.mapList,
pickBanEventCount: data.pickBanEventCount,
});
})();
const banPickingTeam = () => {
return turnOfResult
? teams.find((t) => t.id === turnOfResult.teamId)
: null;
};
const style = {
"--_tournament-bg-url": stage
? `url("${stageNameToBannerImageUrl(stage.stageId)}")`
: undefined,
};
const inBanPhase =
!data.matchIsOver &&
data.match.roundMaps?.pickBan === "BAN_2" &&
data.mapList &&
data.mapList.filter((m) => m.bannedByTournamentTeamId).length < 2;
const waitingForActiveRosterSelectionFor = (() => {
if (data.results.length > 0) return null;
const teamOneMissing = !tournamentTeamToActiveRosterUserIds(
teams[0],
tournament.minMembersPerTeam,
);
const teamTwoMissing = !tournamentTeamToActiveRosterUserIds(
teams[1],
tournament.minMembersPerTeam,
);
if (teamOneMissing && teamTwoMissing) {
return "BOTH";
}
if (teamOneMissing) {
return teams[0].name;
}
if (teamTwoMissing) {
return teams[1].name;
}
return null;
})();
const waitingForLeagueRoundToStart = isLeagueRoundLocked(
tournament,
data.match.roundId,
);
const noStageHeading = () => {
if (data.match.roundMaps?.pickBan === "CUSTOM" && turnOfResult) {
const stepCounter =
turnOfResult.stepTotal && turnOfResult.stepTotal > 1
? ` (${turnOfResult.stepCurrent}/${turnOfResult.stepTotal})`
: "";
switch (turnOfResult.action) {
case "PICK":
return t("tournament:pickBan.pickMap") + stepCounter;
case "BAN":
return t("tournament:pickBan.banMap") + stepCounter;
case "MODE_PICK":
return t("tournament:pickBan.pickMode") + stepCounter;
case "MODE_BAN":
return t("tournament:pickBan.banMode") + stepCounter;
default:
return t("tournament:pickBan.counterpick");
}
}
return t("tournament:pickBan.counterpick");
};
return (
<>
{matchIsLocked ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">
Match locked to be casted
</div>
<div>Please wait for staff to unlock</div>
</div>
</div>
) : waitingForLeagueRoundToStart ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">
Waiting for league round to start
</div>
<div>
Round playable from{" "}
{resolveLeagueRoundStartDate(
tournament,
data.match.roundId,
)!.toLocaleDateString()}{" "}
onwards
</div>
</div>
</div>
) : waitingForPreviousMatch ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">
Previous match ongoing
</div>
<div>
Match will be reportable when both teams are ready to play
</div>
</div>
</div>
) : waitingForActiveRosterSelectionFor ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div
className="text-lg text-center font-bold"
data-testid="active-roster-needed-text"
>
Active rosters need to be selected
</div>
<div>
Waiting on{" "}
{waitingForActiveRosterSelectionFor === "BOTH"
? "both teams"
: waitingForActiveRosterSelectionFor}
</div>
</div>
{data.match.startedAt &&
!tournament.isLeagueDivision &&
(waitingForActiveRosterSelectionFor || !stage || inBanPhase) ? (
<DeadlineInfoPopover
startedAt={databaseTimestampToDate(data.match.startedAt)}
bestOf={data.match.bestOf}
gamesCompleted={gamesCompleted}
/>
) : null}
</div>
) : inBanPhase ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">Banning phase</div>
<div>Waiting for {banPickingTeam()?.name}</div>
</div>
</div>
) : !stage ? (
<div className={styles.lockedBanner}>
<div className="stack sm items-center">
<div className="text-lg text-center font-bold">
{noStageHeading()}
</div>
<div>Waiting for {banPickingTeam()?.name}</div>
{children}
</div>
</div>
) : (
<div
className={clsx(styles.stageBanner, {
rounded: !infos,
})}
style={style}
data-testid="stage-banner"
>
<div className={styles.stageBannerTopBar}>
<h4 className={styles.stageBannerTopBarHeader}>
<Image path={modeImageUrl(stage.mode)} alt="" width={24} />
<span className={styles.stageBannerTopBarMapTextSmall}>
{t(`game-misc:MODE_SHORT_${stage.mode}`)}{" "}
{t(`game-misc:STAGE_${stage.stageId}`)}
</span>
<span className={styles.stageBannerTopBarMapTextBig}>
{t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "}
{t(`game-misc:STAGE_${stage.stageId}`)}
</span>
</h4>
<h4>
{pickInfoText({
t: t as unknown as TFunction<["tournament"]>,
teams,
map: stage,
})}
</h4>
</div>
{data.match.startedAt &&
!tournament.isLeagueDivision &&
!data.matchIsOver ? (
<DeadlineInfoPopover
startedAt={databaseTimestampToDate(data.match.startedAt)}
bestOf={data.match.bestOf}
gamesCompleted={gamesCompleted}
/>
) : null}
{children}
</div>
)}
{(tournament.isOrganizer(user) ||
teams.some((t) => t.members.some((m) => m.userId === user?.id))) &&
!tournament.isLeagueDivision &&
!matchIsLocked &&
data.match.startedAt &&
!data.matchIsOver ? (
<MatchTimer
startedAt={databaseTimestampToDate(data.match.startedAt)}
bestOf={data.match.bestOf}
/>
) : null}
{infos && (
<div className={styles.infos}>
{infos.filter(Boolean).map((info, i) => (
<div key={i}>{info}</div>
))}
</div>
)}
</>
);
}
function ModeProgressIndicator({
scores,
bestOf,
selectedResultIndex,
setSelectedResultIndex,
}: {
scores: [number, number];
bestOf: number;
selectedResultIndex?: number;
setSelectedResultIndex?: (index: number) => void;
}) {
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["game-misc"]);
const maxIndexThatWillBePlayedForSure =
data.match.roundMaps?.type === "PLAY_ALL"
? bestOf - 1
: mapCountPlayedInSetWithCertainty({ bestOf, scores }) - 1;
const indexWithBansConsider = (realIdx: number) => {
let result = 0;
for (const [idx, map] of (data.mapList ?? []).entries()) {
if (idx === realIdx) {
break;
}
if (map.bannedByTournamentTeamId) {
continue;
}
result++;
}
return result;
};
// TODO: this should be button when we click on it
return (
<div className={styles.modeProgress}>
<div className={styles.modeProgressInner}>
{nullFilledArray(
Math.max(data.mapList?.length ?? 0, data.match.roundMaps?.count ?? 0),
).map((_, i) => {
const map = data.mapList?.[i];
const adjustedI = indexWithBansConsider(i);
if (
data.matchIsOver &&
!data.results[adjustedI] &&
!map?.bannedByTournamentTeamId
) {
return null;
}
if (!map?.mode) {
return (
<div key={i} className={styles.modeProgressImage}>
<MousePointerClick />
</div>
);
}
if (map.bannedByTournamentTeamId) {
const bannerTeamName = tournament.ctx.teams.find(
(t) => t.id === map.bannedByTournamentTeamId,
)?.name;
return (
<SendouPopover
key={i}
trigger={
<SendouButton
variant="minimal"
size="small"
className={styles.modeProgressImageBannedPopoverTrigger}
>
<Image
containerClassName={clsx(
styles.modeProgressImage,
styles.modeProgressImageBanned,
)}
path={modeImageUrl(map.mode)}
height={20}
width={20}
alt={t(`game-misc:MODE_LONG_${map.mode}`)}
testId="mode-progress-banned"
/>
</SendouButton>
}
>
<div className="text-center">
{t(`game-misc:MODE_SHORT_${map.mode}`)}{" "}
{t(`game-misc:STAGE_${map.stageId}`)}
</div>
<div className="text-xs text-lighter">
Banned by {bannerTeamName}
</div>
</SendouPopover>
);
}
return (
<Image
containerClassName={clsx(styles.modeProgressImage, {
[styles.modeProgressImageNotable]:
adjustedI <= maxIndexThatWillBePlayedForSure,
[styles.modeProgressImageTeamOneWin]:
data.results[adjustedI] &&
data.results[adjustedI].winnerTeamId ===
data.match.opponentOne?.id,
[styles.modeProgressImageTeamTwoWin]:
data.results[adjustedI] &&
data.results[adjustedI].winnerTeamId ===
data.match.opponentTwo?.id,
[styles.modeProgressImageSelected]:
adjustedI === selectedResultIndex,
"cursor-pointer": Boolean(setSelectedResultIndex),
})}
key={i}
path={modeImageUrl(map.mode)}
height={20}
width={20}
alt={t(`game-misc:MODE_LONG_${map.mode}`)}
title={t(`game-misc:MODE_LONG_${map.mode}`)}
onClick={() => setSelectedResultIndex?.(adjustedI)}
testId={`mode-progress-${map.mode}`}
/>
);
})}
</div>
</div>
);
}
function StartedMatchTabs({
presentational,
scores,
teams,
result,
}: {
presentational?: boolean;
scores: [number, number];
teams: [TournamentDataTeam, TournamentDataTeam];
result?: Result;
}) {
const user = useUser();
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
const validTabs = ["rosters", "actions"];
const [selectedTabKey, setSelectedTabKey] = useSearchParamState({
defaultValue: "rosters",
name: "tab",
revive: (value) => (validTabs.includes(value) ? value : null),
});
const currentPosition = scores[0] + scores[1];
const matchActionsKey = () =>
[
data.match.id,
tournamentTeamToActiveRosterUserIds(
teams[0],
tournament.minMembersPerTeam,
),
tournamentTeamToActiveRosterUserIds(
teams[1],
tournament.minMembersPerTeam,
),
result?.participants
.map((p) => `${p.userId}-${p.tournamentTeamId}`)
.join(","),
result?.opponentOnePoints,
result?.opponentTwoPoints,
data.results.length,
].join("-");
return (
<ActionSectionWrapper>
<SendouTabs
selectedKey={selectedTabKey}
onSelectionChange={(key) => setSelectedTabKey(String(key))}
className={styles.matchTabs}
>
<SendouTabList>
<SendouTab id="rosters">Rosters</SendouTab>
<SendouTab id="actions" data-testid="actions-tab">
{presentational ? "Score" : "Actions"}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="rosters">
<MatchRosters teams={[teams[0].id, teams[1].id]} />
</SendouTabPanel>
<SendouTabPanel
id="actions"
shouldForceMount
className={clsx({
hidden: selectedTabKey !== "actions",
})}
>
<MatchActions
// Without the key prop when switching to another match the winnerId is remembered
// which causes "No winning team matching the id" error.
// In addition we want the active roster changing either by the user or by another user
// to reset the state inside. We also want to clear the inputs when a result is submitted
key={matchActionsKey()}
scores={scores}
teams={teams}
position={currentPosition}
result={result}
presentational={
!tournament.canReportScore({ matchId: data.match.id, user })
}
/>
</SendouTabPanel>
</SendouTabs>
</ActionSectionWrapper>
);
}
function ActionSectionWrapper({ children }: { children: React.ReactNode }) {
return <div className={styles.actionSectionWrapper}>{children}</div>;
}
function ScreenBanIcons({ banned }: { banned: boolean }) {
const { t } = useTranslation(["weapons"]);
return (
<div
className={clsx(styles.noScreen, {
[styles.noScreenBanned]: banned,
})}
data-testid={`screen-${banned ? "banned" : "allowed"}`}
>
{banned ? <X /> : <Check />}
<Image
path={specialWeaponImageUrl(SPLATTERCOLOR_SCREEN_ID)}
width={24}
height={24}
alt={t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)}
/>
</div>
);
}
function EndSetPopover({
teams,
}: {
teams: [TournamentDataTeam, TournamentDataTeam];
}) {
const { t } = useTranslation(["tournament"]);
const [selectedWinner, setSelectedWinner] = React.useState<
number | null | undefined
>(undefined);
return (
<SendouPopover
placement="top"
trigger={
<SendouButton
className={clsx(
styles.stageBannerUndoButton,
styles.stageBannerEndSetButton,
)}
size="miniscule"
variant="destructive"
>
{t("tournament:match.action.endSet")}
</SendouButton>
}
>
<Form method="post" className="stack md">
<div className="stack sm">
<Label className="mx-auto">
{t("tournament:match.endSet.selectWinner")}
</Label>
<label className="stack horizontal sm items-center">
<input
type="radio"
name="winnerSelection"
value="random"
checked={selectedWinner === null}
onChange={() => setSelectedWinner(null)}
/>
<span>{t("tournament:match.endSet.randomWinner")}</span>
</label>
<label className="stack horizontal sm items-center">
<input
type="radio"
name="winnerSelection"
value={teams[0].id}
checked={selectedWinner === teams[0].id}
onChange={() => setSelectedWinner(teams[0].id)}
/>
<span>{teams[0].name}</span>
</label>
<label className="stack horizontal sm items-center">
<input
type="radio"
name="winnerSelection"
value={teams[1].id}
checked={selectedWinner === teams[1].id}
onChange={() => setSelectedWinner(teams[1].id)}
/>
<span>{teams[1].name}</span>
</label>
</div>
<input
type="hidden"
name="winnerTeamId"
value={selectedWinner === null ? "null" : (selectedWinner ?? "")}
/>
<SubmitButton
_action="END_SET"
testId="end-set-button"
size="miniscule"
className="mx-auto"
isDisabled={selectedWinner === undefined}
>
{t("tournament:match.action.confirmEndSet")}
</SubmitButton>
</Form>
</SendouPopover>
);
}

View File

@ -0,0 +1,103 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s-6);
}
.castSection {
display: flex;
flex-direction: column;
gap: var(--s-4);
align-items: flex-start;
}
.castLabelRow {
display: flex;
align-items: center;
gap: var(--s-2);
& label {
text-box: trim-both cap alphabetic;
}
}
.castEmptyHint {
color: var(--color-text-high);
font-size: var(--font-xs);
margin: 0;
}
.lockRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.buttonRow {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
align-items: center;
justify-content: center;
}
.editSection {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.resultList {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.resultRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
background-color: var(--color-bg-higher);
border-radius: var(--radius-box);
}
.resultRowEditing {
display: flex;
flex-direction: column;
gap: var(--s-3);
padding: var(--s-3);
background-color: var(--color-bg-higher);
border-radius: var(--radius-box);
}
.mapIndex {
font-weight: var(--weight-semi);
margin-right: var(--s-2);
}
.winnerName {
color: var(--color-text-high);
}
.rosterColumns {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--s-3);
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.teamFieldset {
border: 2px solid var(--color-border);
border-radius: var(--radius-box);
padding: var(--s-2);
& legend {
padding: 0 var(--s-2);
font-weight: var(--weight-semi);
}
}

View File

@ -0,0 +1,585 @@
import { Ban, Lock, LockOpen, RotateCcw, SquarePen } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Form, useFetcher } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouTabPanel } from "~/components/elements/Tabs";
import { toastQueue } from "~/components/elements/Toast";
import { InfoPopover } from "~/components/InfoPopover";
import { Label } from "~/components/Label";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { SubmitButton } from "~/components/SubmitButton";
import { TournamentMatchStatus } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { OrganizerMatchMapListDialog } from "./OrganizerMatchMapListDialog";
import styles from "./TournamentMatchAdminTab.module.css";
const NOT_CASTED_VALUE = "null";
export function TournamentMatchAdminTab({
data,
}: {
data: TournamentMatchLoaderData;
}) {
const user = useUser();
const tournament = useTournament();
const opponentOneId = data.match.opponentOne?.id;
const opponentTwoId = data.match.opponentTwo?.id;
const teamOne = opponentOneId
? tournament.teamById(opponentOneId)
: undefined;
const teamTwo = opponentTwoId
? tournament.teamById(opponentTwoId)
: undefined;
const scoreOne = data.match.opponentOne?.score ?? 0;
const scoreTwo = data.match.opponentTwo?.score ?? 0;
const matchIsOngoing = scoreOne > 0 || scoreTwo > 0;
const isOrganizer = tournament.isOrganizer(user);
const canReopen =
isOrganizer &&
data.matchIsOver &&
tournament.matchCanBeReopened(data.match.id);
const canEndSet =
isOrganizer && !data.matchIsOver && data.match.startedAt !== null;
const topActionsVisible = !!teamOne && !!teamTwo;
const castSectionVisible = !data.matchIsOver;
const editScoresVisible =
isOrganizer && !!teamOne && !!teamTwo && data.results.length > 0;
return (
<SendouTabPanel id={TAB_KEYS.ADMIN}>
<div className={styles.root}>
{topActionsVisible ? (
<div className={styles.buttonRow}>
<OrganizerMatchMapListDialog data={data} />
{canReopen ? <ReopenMatchButton /> : null}
{canEndSet ? <EndSetPopover teams={[teamOne!, teamTwo!]} /> : null}
</div>
) : null}
{castSectionVisible ? (
<AdminCastSection
matchIsOngoing={matchIsOngoing}
matchId={data.match.id}
matchStatus={data.match.status}
/>
) : null}
{editScoresVisible ? (
<EditReportedScoresSection data={data} teams={[teamOne!, teamTwo!]} />
) : null}
</div>
</SendouTabPanel>
);
}
const LOCKING_INFO =
"You can lock the match to indicate that it should not be started before the cast is ready. Match being locked prevents score reporting and hides the map list till the organizer/streamer unlocks it.";
const SET_AS_CASTED_INFO =
"Select the Twitch account that is currently casting this match. It is then indicated in the bracket view.";
function AdminCastSection({
matchIsOngoing,
matchId,
matchStatus,
}: {
matchIsOngoing: boolean;
matchId: number;
matchStatus: number;
}) {
const tournament = useTournament();
const castTwitchAccounts = tournament.ctx.castTwitchAccounts ?? [];
const castedMatchesInfo = tournament.ctx.castedMatchesInfo;
const currentlyCastedOn =
castedMatchesInfo?.castedMatches.find((cm) => cm.matchId === matchId)
?.twitchAccount ?? null;
const isLocked =
castedMatchesInfo?.lockedMatches?.some((lm) => lm.matchId === matchId) ??
false;
const canLock =
(matchStatus === TournamentMatchStatus.Locked ||
matchStatus === TournamentMatchStatus.Waiting) &&
!isLocked;
const canUnlock = !matchIsOngoing && isLocked;
return (
<section className={styles.castSection}>
<div className={styles.castLabelRow}>
<Label spaced={false}>Cast</Label>
<InfoPopover tiny>{SET_AS_CASTED_INFO}</InfoPopover>
</div>
{castTwitchAccounts.length === 0 ? (
<p className={styles.castEmptyHint}>
Configure streaming channels on the tournament admin page to enable
casting.
</p>
) : (
<>
<CastChannelChipRadio
matchId={matchId}
accounts={castTwitchAccounts}
currentlyCastedOn={currentlyCastedOn}
/>
{canLock || canUnlock ? (
<LockToggleButton
isLocked={isLocked}
twitchAccount={currentlyCastedOn}
/>
) : null}
</>
)}
</section>
);
}
function CastChannelChipRadio({
matchId,
accounts,
currentlyCastedOn,
}: {
matchId: number;
accounts: string[];
currentlyCastedOn: string | null;
}) {
const fetcher = useFetcher();
const previousStateRef = React.useRef(fetcher.state);
React.useEffect(() => {
if (
previousStateRef.current !== "idle" &&
fetcher.state === "idle" &&
!(fetcher.data as { error?: unknown } | undefined)?.error
) {
toastQueue.add(
{ message: "Cast channel updated", variant: "success" },
{ timeout: 5000 },
);
}
previousStateRef.current = fetcher.state;
}, [fetcher.state, fetcher.data]);
const selectedValue = currentlyCastedOn ?? NOT_CASTED_VALUE;
const handleChange = (value: string) => {
if (value === selectedValue) return;
fetcher.submit(
{ _action: "SET_AS_CASTED", twitchAccount: value },
{ method: "post" },
);
};
return (
<SendouChipRadioGroup>
<SendouChipRadio
name={`cast-channel-${matchId}`}
value={NOT_CASTED_VALUE}
checked={selectedValue === NOT_CASTED_VALUE}
onChange={handleChange}
>
Not casted
</SendouChipRadio>
{accounts.map((account) => (
<SendouChipRadio
key={account}
name={`cast-channel-${matchId}`}
value={account}
checked={selectedValue === account}
onChange={handleChange}
>
{account}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
);
}
function LockToggleButton({
isLocked,
twitchAccount,
}: {
isLocked: boolean;
twitchAccount: string | null;
}) {
return (
<Form method="post" className={styles.lockRow}>
{isLocked ? (
<SubmitButton
_action="UNLOCK"
size="small"
icon={<LockOpen size={16} />}
testId="cast-info-submit-button"
>
Unlock
</SubmitButton>
) : (
<>
<input
type="hidden"
name="twitchAccount"
value={twitchAccount ?? ""}
/>
<SubmitButton
_action="LOCK"
size="small"
icon={<Lock size={16} />}
isDisabled={!twitchAccount}
testId="cast-info-submit-button"
>
Lock to be casted
</SubmitButton>
</>
)}
<InfoPopover>{LOCKING_INFO}</InfoPopover>
</Form>
);
}
function ReopenMatchButton() {
const { t } = useTranslation(["tournament"]);
return (
<Form method="post">
<SubmitButton
_action="REOPEN_MATCH"
variant="destructive"
size="small"
icon={<RotateCcw size={16} />}
testId="reopen-match-button"
>
{t("tournament:match.action.reopenMatch")}
</SubmitButton>
</Form>
);
}
function EndSetPopover({
teams,
}: {
teams: [TournamentDataTeam, TournamentDataTeam];
}) {
const { t } = useTranslation(["tournament"]);
const [selectedWinner, setSelectedWinner] = React.useState<
number | null | undefined
>(undefined);
return (
<SendouPopover
placement="top"
trigger={
<SendouButton
size="small"
variant="destructive"
icon={<Ban size={16} />}
>
{t("tournament:match.action.endSet")}
</SendouButton>
}
>
<Form method="post" className="stack md">
<div className="stack sm">
<Label className="mx-auto">
{t("tournament:match.endSet.selectWinner")}
</Label>
<label className="stack horizontal sm items-center">
<input
type="radio"
name="winnerSelection"
value="random"
checked={selectedWinner === null}
onChange={() => setSelectedWinner(null)}
/>
<span>{t("tournament:match.endSet.randomWinner")}</span>
</label>
<label className="stack horizontal sm items-center">
<input
type="radio"
name="winnerSelection"
value={teams[0].id}
checked={selectedWinner === teams[0].id}
onChange={() => setSelectedWinner(teams[0].id)}
/>
<span>{teams[0].name}</span>
</label>
<label className="stack horizontal sm items-center">
<input
type="radio"
name="winnerSelection"
value={teams[1].id}
checked={selectedWinner === teams[1].id}
onChange={() => setSelectedWinner(teams[1].id)}
/>
<span>{teams[1].name}</span>
</label>
</div>
<input
type="hidden"
name="winnerTeamId"
value={selectedWinner === null ? "null" : (selectedWinner ?? "")}
/>
<SubmitButton
_action="END_SET"
testId="end-set-button"
size="small"
className="mx-auto"
isDisabled={selectedWinner === undefined}
>
{t("tournament:match.action.confirmEndSet")}
</SubmitButton>
</Form>
</SendouPopover>
);
}
function EditReportedScoresSection({
data,
teams,
}: {
data: TournamentMatchLoaderData;
teams: [TournamentDataTeam, TournamentDataTeam];
}) {
const tournament = useTournament();
const withPoints = tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectResultsWithPoints;
return (
<div className={styles.editSection}>
<Label>Edit reported scores</Label>
<div className={styles.resultList}>
{data.results.map((result, index) => (
<EditReportedScoreRow
key={result.id}
index={index}
result={result}
teams={teams}
withPoints={withPoints}
/>
))}
</div>
</div>
);
}
function EditReportedScoreRow({
index,
result,
teams,
withPoints,
}: {
index: number;
result: TournamentMatchLoaderData["results"][number];
teams: [TournamentDataTeam, TournamentDataTeam];
withPoints: boolean;
}) {
const tournament = useTournament();
const fetcher = useFetcher();
const [editing, setEditing] = React.useState(false);
const winnerName =
result.winnerTeamId === teams[0].id ? teams[0].name : teams[1].name;
const pointsText = (() => {
if (
result.opponentOnePoints === null ||
result.opponentTwoPoints === null
) {
return "";
}
if (result.opponentOnePoints === 100 || result.opponentTwoPoints === 100) {
return " (KO)";
}
return ` (${result.opponentOnePoints}p-${result.opponentTwoPoints}p)`;
})();
if (!editing) {
return (
<div className={styles.resultRow}>
<div>
<span className={styles.mapIndex}>Map {index + 1}</span>
<span className={styles.winnerName}>
{winnerName} won{pointsText}
</span>
</div>
<SendouButton
icon={<SquarePen />}
variant="outlined"
size="small"
onPress={() => setEditing(true)}
data-testid={`edit-result-${index}-button`}
>
Edit
</SendouButton>
</div>
);
}
return (
<EditReportedScoreForm
fetcher={fetcher}
result={result}
teams={teams}
withPoints={withPoints}
minMembersPerTeam={tournament.minMembersPerTeam}
onCancel={() => setEditing(false)}
index={index}
/>
);
}
function EditReportedScoreForm({
fetcher,
result,
teams,
withPoints,
minMembersPerTeam,
onCancel,
index,
}: {
fetcher: ReturnType<typeof useFetcher>;
result: TournamentMatchLoaderData["results"][number];
teams: [TournamentDataTeam, TournamentDataTeam];
withPoints: boolean;
minMembersPerTeam: number;
onCancel: () => void;
index: number;
}) {
const initialRosters = React.useMemo<[number[], number[]]>(() => {
return [
result.participants
.filter((p) => p.tournamentTeamId === teams[0].id)
.map((p) => p.userId),
result.participants
.filter((p) => p.tournamentTeamId === teams[1].id)
.map((p) => p.userId),
];
}, [result, teams]);
const [checkedPlayers, setCheckedPlayers] =
React.useState<[number[], number[]]>(initialRosters);
const [points, setPoints] = React.useState<[number, number]>([
result.opponentOnePoints ?? 0,
result.opponentTwoPoints ?? 0,
]);
const rosterValid = checkedPlayers.every(
(team) => team.length === minMembersPerTeam,
);
const pointsValid = (() => {
if (!withPoints) return true;
if (points[0] === points[1]) return false;
if (points[0] === 100 && points[1] !== 0) return false;
if (points[1] === 100 && points[0] !== 0) return false;
const originalWinnerWasOne =
(result.opponentOnePoints ?? 0) > (result.opponentTwoPoints ?? 0);
return originalWinnerWasOne ? points[0] > points[1] : points[1] > points[0];
})();
const formValid = rosterValid && pointsValid;
const togglePlayer = (teamIdx: 0 | 1, userId: number) => {
setCheckedPlayers((prev) => {
const next: [number[], number[]] = [prev[0].slice(), prev[1].slice()];
if (next[teamIdx].includes(userId)) {
next[teamIdx] = next[teamIdx].filter((id) => id !== userId);
} else {
next[teamIdx] = [...next[teamIdx], userId];
}
return next;
});
};
return (
<fetcher.Form method="post" className={styles.resultRowEditing}>
<div className={styles.rosterColumns}>
{teams.map((team, teamIdx) => (
<fieldset key={team.id} className={styles.teamFieldset}>
<legend>{team.name}</legend>
<div className="stack sm">
{team.members.map((member) => {
const checked = checkedPlayers[teamIdx as 0 | 1].includes(
member.userId,
);
return (
<label
key={member.userId}
className="stack horizontal sm items-center"
>
<input
type="checkbox"
checked={checked}
onChange={() =>
togglePlayer(teamIdx as 0 | 1, member.userId)
}
/>
<span>{member.username}</span>
</label>
);
})}
</div>
{withPoints ? (
<div className="stack xs mt-2">
<Label>Points</Label>
<input
type="number"
min={0}
value={points[teamIdx as 0 | 1]}
onChange={(e) => {
const value = Number(e.target.value);
setPoints((prev) => {
const next: [number, number] = [prev[0], prev[1]];
next[teamIdx as 0 | 1] = Number.isFinite(value)
? value
: 0;
return next;
});
}}
/>
</div>
) : null}
</fieldset>
))}
</div>
<input type="hidden" name="resultId" value={result.id} />
<input
type="hidden"
name="rosters"
value={JSON.stringify(checkedPlayers)}
/>
{withPoints ? (
<input type="hidden" name="points" value={JSON.stringify(points)} />
) : null}
<div className={styles.buttonRow}>
<SubmitButton
size="small"
state={fetcher.state}
_action="UPDATE_REPORTED_SCORE"
isDisabled={!formValid}
testId={`save-result-${index}-button`}
>
Save
</SubmitButton>
<SendouButton variant="destructive" size="small" onPress={onCancel}>
Cancel
</SendouButton>
</div>
</fetcher.Form>
);
}

View File

@ -1,5 +1,5 @@
import { differenceInMinutes } from "date-fns";
import { Check, Users, X } from "lucide-react";
import { Check, Lock, Users, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
IconBanner,
@ -10,6 +10,10 @@ import {
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { useTournament } from "~/features/tournament/routes/to.$id";
import {
isLeagueRoundLocked,
resolveLeagueRoundStartDate,
} from "~/features/tournament/tournament-utils";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
@ -36,6 +40,11 @@ export function TournamentMatchBanner({
tournament,
);
const leagueRoundLocked = isLeagueRoundLocked(tournament, data.match.roundId);
const leagueRoundStartDate = leagueRoundLocked
? resolveLeagueRoundStartDate(tournament, data.match.roundId)
: null;
const pickBanBanner = resolvePickBanBanner(data, tournament, t);
const activeRosterByTeamId = (tournamentTeamId: number) => {
@ -53,7 +62,19 @@ export function TournamentMatchBanner({
return (
<MatchBannerContainer>
<TournamentMatchBannerTopRow data={data} />
{teamsMissingActiveRoster.length > 0 ? (
{leagueRoundLocked ? (
<IconBanner
icon={<Lock size={32} />}
header={t("tournament:match.leagueLocked.header")}
subtitle={
leagueRoundStartDate
? t("tournament:match.leagueLocked.subtitle", {
date: leagueRoundStartDate.toLocaleDateString(),
})
: undefined
}
/>
) : teamsMissingActiveRoster.length > 0 ? (
<IconBanner
icon={<Users size={32} />}
header={t("tournament:match.activeRosterMissing.header")}

View File

@ -7,6 +7,7 @@ import { MatchTabs } from "~/components/match-page/MatchTabs";
import type { TimelineMap } from "~/components/match-page/MatchTimeline";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import {
groupNumberToLetters,
@ -18,6 +19,7 @@ import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.s
import { resolveHostingTeam, resolveRoomPass } from "../tournament-match-utils";
import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab";
import { TournamentMatchActionTab } from "./TournamentMatchActionTab";
import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab";
import { TournamentMatchPickBanTab } from "./TournamentMatchPickBanTab";
export function TournamentMatchTabs({
@ -74,6 +76,11 @@ export function TournamentMatchTabs({
const hasPickBanSetup =
Boolean(data.match.roundMaps?.pickBan) && !!pickBanTeams;
const isAdminEligible =
tournament.isOrganizerOrStreamer(user) && !tournament.ctx.isFinalized;
const leagueRoundLocked = isLeagueRoundLocked(tournament, data.match.roundId);
const tabs = resolveVisibleTabs({
matchIsOver: data.matchIsOver,
canReportScore,
@ -82,6 +89,8 @@ export function TournamentMatchTabs({
hasMissingActiveRoster,
isPickBanStep,
hasPickBanSetup,
isAdminEligible,
leagueRoundLocked,
});
const userTeamId = tournament.teamMemberOfByUser(user)?.id;
@ -119,6 +128,7 @@ export function TournamentMatchTabs({
/>
) : null
) : null}
{tabs.includes("admin") ? <TournamentMatchAdminTab data={data} /> : null}
</MatchTabs>
);
}
@ -376,6 +386,8 @@ function resolveVisibleTabs({
hasMissingActiveRoster,
isPickBanStep,
hasPickBanSetup,
isAdminEligible,
leagueRoundLocked,
}: {
matchIsOver: boolean;
canReportScore: boolean;
@ -384,25 +396,33 @@ function resolveVisibleTabs({
hasMissingActiveRoster: boolean;
isPickBanStep: boolean;
hasPickBanSetup: boolean;
isAdminEligible: boolean;
leagueRoundLocked: boolean;
}) {
const tabs: Array<"join" | "rosters" | "pickBan" | "action" | "result"> = [];
const tabs: Array<
"join" | "rosters" | "pickBan" | "action" | "result" | "admin"
> = [];
if (matchIsOver) {
tabs.push("result");
}
if (!matchIsOver && isParticipant) {
if (!matchIsOver && isParticipant && !leagueRoundLocked) {
tabs.push("join");
}
tabs.push("rosters");
if (
isPickBanStep ||
(canReportScore && hasCurrentMap && !hasMissingActiveRoster)
!leagueRoundLocked &&
(isPickBanStep ||
(canReportScore && hasCurrentMap && !hasMissingActiveRoster))
) {
tabs.push("action");
}
if (hasPickBanSetup) {
tabs.push("pickBan");
}
if (isAdminEligible) {
tabs.push("admin");
}
return tabs;
}

View File

@ -96,9 +96,11 @@ export function endDroppedTeamMatches({
{
id: match.id,
opponent1: {
score: match.opponent1.score,
result: winnerTeamId === match.opponent1.id ? "win" : "loss",
},
opponent2: {
score: match.opponent2.score,
result: winnerTeamId === match.opponent2.id ? "win" : "loss",
},
},

View File

@ -1,19 +0,0 @@
// https://github.com/aholachek/react-flip-toolkit/issues/95#issuecomment-546101332
/**
* Thin wrapper around Element.animate() that returns a Promise
* @param el Element to animate
* @param keyframes The keyframes to use when animating
* @param options Either the duration of the animation or an options argument detailing how the animation should be performed
* @returns A promise that will resolve after the animation completes or is cancelled
*/
export function animate(
el: HTMLElement,
keyframes: Keyframe[] | PropertyIndexedKeyframes,
options?: number | KeyframeAnimationOptions,
): Promise<void> {
return new Promise((resolve) => {
const anim = el.animate(keyframes, options);
anim.addEventListener("finish", () => resolve());
anim.addEventListener("cancel", () => resolve());
});
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -113,6 +113,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Spil alle runder {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Annuller sidste score",
"match.action.reopenMatch": "Genåbn kamp",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -113,6 +113,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Letztes Ergebnis widerrufen",
"match.action.reopenMatch": "Match erneut öffnen",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "Continue queueing with the group of {{count}}?",
"match.rematch.vote.yes": "Yes, continue",
"match.rematch.vote.no": "No, I'm done",
"match.rematch.vote.noConfirm": "Vote no? You can't change your vote afterwards.",
"match.rematch.declined": "You declined to continue",
"match.rematch.fizzled": "Nobody wanted to continue",
"match.rematch.waitingCaptain": "Waiting for the captain to choose whether to re-queue",

View File

@ -113,6 +113,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
"match.activeRosterMissing.header": "Active roster needed",
"match.activeRosterMissing.subtitle": "Waiting on {{teams}}",
"match.leagueLocked.header": "Waiting for league round to start",
"match.leagueLocked.subtitle": "Round playable from {{date}} onwards",
"match.action.undoLastScore": "Undo last score",
"match.action.reopenMatch": "Reopen match",
"match.action.endSet": "End set",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -115,6 +115,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Anular resultado previo",
"match.action.reopenMatch": "Reabrir partido",
"match.action.endSet": "Finalizar set",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -115,6 +115,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Anular resultado previo",
"match.action.reopenMatch": "Reabrir partido",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -115,6 +115,8 @@
"match.score.playAll": "",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Annuler le dernier score",
"match.action.reopenMatch": "Rouvrir le match",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -115,6 +115,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Tous jouer {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Annuler le dernier score",
"match.action.reopenMatch": "Rouvrir le match",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -115,6 +115,8 @@
"match.score.playAll": "",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "בטלו את התוצאה האחרונה",
"match.action.reopenMatch": "פתיחה מחדש של הקרב",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -115,6 +115,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Annulla ultimo punteggio",
"match.action.reopenMatch": "Riapri match",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -109,6 +109,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全てプレイする {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "最後のスコアをやりなおす",
"match.action.reopenMatch": "対戦を再度開く",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -109,6 +109,8 @@
"match.score.playAll": "",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "",
"match.action.reopenMatch": "",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -113,6 +113,8 @@
"match.score.playAll": "",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "",
"match.action.reopenMatch": "",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -117,6 +117,8 @@
"match.score.playAll": "",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "",
"match.action.reopenMatch": "",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -115,6 +115,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jogar todas {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Desfazer última pontuação",
"match.action.reopenMatch": "Reabrir partida",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -117,6 +117,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Играть все {{bestOf}})",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "Отменить последний результат",
"match.action.reopenMatch": "Открыть матч заново",
"match.action.endSet": "",

View File

@ -211,6 +211,7 @@
"match.rematch.prompt": "",
"match.rematch.vote.yes": "",
"match.rematch.vote.no": "",
"match.rematch.vote.noConfirm": "",
"match.rematch.declined": "",
"match.rematch.fizzled": "",
"match.rematch.waitingCaptain": "",

View File

@ -109,6 +109,8 @@
"match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全部 {{bestOf}} 场)",
"match.activeRosterMissing.header": "",
"match.activeRosterMissing.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.action.undoLastScore": "撤销上次比分",
"match.action.reopenMatch": "重新开始对战",
"match.action.endSet": "",