{layout === "STAGE_BY_MODE" ? (
@@ -296,6 +301,9 @@ function StageTile({
onClick={onSelect}
disabled={disabled}
data-testid="pick-ban-button"
+ aria-label={`${type === "PICK" ? "Pick" : "Ban"} ${shortStageName(
+ t(`game-misc:STAGE_${option.stageId!}`),
+ )}`}
/>
{isSelected ? (
type === "PICK" ? (
diff --git a/app/components/match-page/MatchBanner.module.css b/app/components/match-page/MatchBanner.module.css
index 2373d8c2c..76bd5ecbb 100644
--- a/app/components/match-page/MatchBanner.module.css
+++ b/app/components/match-page/MatchBanner.module.css
@@ -163,7 +163,7 @@
.iconBannerSubtitle {
font-size: var(--font-xs);
- color: var(--color-text-low);
+ color: var(--color-text-high);
}
.iconBannerBottomRight {
diff --git a/app/components/match-page/MatchBanner.tsx b/app/components/match-page/MatchBanner.tsx
index 02944d369..a5c08a52e 100644
--- a/app/components/match-page/MatchBanner.tsx
+++ b/app/components/match-page/MatchBanner.tsx
@@ -154,6 +154,7 @@ function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
variant="minimal"
className={styles.notice}
testId={screenLegal ? "screen-allowed" : "screen-banned"}
+ aria-label={screenLegal ? "Screen allowed" : "Screen banned"}
>
;
+ games: Array<{ mode: ModeShort | null }>;
activeRosters: {
alpha: CommonUser[] | null;
bravo: CommonUser[] | null;
diff --git a/app/components/match-page/MatchRosterTab.tsx b/app/components/match-page/MatchRosterTab.tsx
index 9b869ec6c..4314554d7 100644
--- a/app/components/match-page/MatchRosterTab.tsx
+++ b/app/components/match-page/MatchRosterTab.tsx
@@ -347,7 +347,11 @@ function MemberTierPopover({
return (
+
{tier === "CALCULATING" ? (
;
+ /** Tabs that should show a warning-colored alert icon to draw attention. */
+ alertTabs?: Array;
}
const TAB_KEY = "tab";
@@ -48,7 +50,7 @@ const TAB_TRANSLATION_KEYS = {
admin: "common:pages.admin",
} as const;
-export function MatchTabs({ children, tabs }: MatchTabsProps) {
+export function MatchTabs({ children, tabs, alertTabs }: MatchTabsProps) {
const { t } = useTranslation(["q", "common"]);
const [searchParams, setSearchParams] = useSearchParams();
@@ -75,7 +77,12 @@ export function MatchTabs({ children, tabs }: MatchTabsProps) {
>
{tabs.map((tab) => (
-
+
{t(TAB_TRANSLATION_KEYS[tab])}
))}
diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx
index 6b38b79be..392fc6978 100644
--- a/app/features/match-page-test/routes/match-page-test.tsx
+++ b/app/features/match-page-test/routes/match-page-test.tsx
@@ -98,11 +98,7 @@ export default function MatchPageTestRoute() {
screenLegal={false}
/>
{
await trx
@@ -1070,6 +1074,7 @@ async function finalizeMatch({
.set({
confirmedAt: dateToDatabaseTimestamp(new Date()),
confirmedByUserId,
+ cancelRequestedByUserId: null,
})
.where("id", "=", match.id)
.execute();
diff --git a/app/features/sendouq-match/components/SendouQMatchBanner.tsx b/app/features/sendouq-match/components/SendouQMatchBanner.tsx
index 252f621ff..f9138506f 100644
--- a/app/features/sendouq-match/components/SendouQMatchBanner.tsx
+++ b/app/features/sendouq-match/components/SendouQMatchBanner.tsx
@@ -46,12 +46,6 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
({
mode: map.mode,
- winner:
- map.winnerGroupId === data.match.groupAlpha.id
- ? "ALPHA"
- : map.winnerGroupId === data.match.groupBravo.id
- ? "BRAVO"
- : undefined,
}))}
activeRosters={{
alpha: data.match.groupAlpha.members,
@@ -87,7 +81,15 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
const currentMap = data.match.currentMap;
invariant(currentMap);
- const joinPool = `SQ${String(data.match.id).at(-1)}`;
+ const isParticipant = Boolean(
+ SendouQMatch.resolveGroupMemberOf({
+ groupAlpha: data.match.groupAlpha,
+ groupBravo: data.match.groupBravo,
+ userId: user?.id,
+ }),
+ );
+
+ const joinPool = isParticipant ? `SQ${String(data.match.id).at(-1)}` : null;
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: data.match.createdAt,
diff --git a/app/features/sendouq-match/components/SendouQMatchTabs.tsx b/app/features/sendouq-match/components/SendouQMatchTabs.tsx
index 277ca1da8..e9aa32c3a 100644
--- a/app/features/sendouq-match/components/SendouQMatchTabs.tsx
+++ b/app/features/sendouq-match/components/SendouQMatchTabs.tsx
@@ -76,11 +76,29 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
(m) => m.winnerGroupId !== null,
);
+ const decidingReportedByUserId = [...data.match.mapList]
+ .reverse()
+ .find((m) => m.winnerGroupId !== null)?.reportedByUserId;
+ const reporterSide = SendouQMatch.resolveGroupMemberOf({
+ groupAlpha: data.match.groupAlpha,
+ groupBravo: data.match.groupBravo,
+ userId: decidingReportedByUserId,
+ });
+ const viewerMustConfirmScore =
+ awaitingConfirmation &&
+ isParticipant &&
+ reporterSide !== null &&
+ reporterSide !== userSide;
+
const tabs: Array<"join" | "rosters" | "action" | "result"> = ["rosters"];
if (!isLocked && isParticipant) tabs.push("join");
if (showActionTab) tabs.push("action");
if (isLocked || hasReportedMaps) tabs.push("result");
+ const alertTabs: Array<"action"> | undefined = viewerMustConfirmScore
+ ? ["action"]
+ : undefined;
+
const allMembers = [
...data.match.groupAlpha.members,
...data.match.groupBravo.members,
@@ -109,7 +127,7 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
aboutUser={addingNoteFor}
close={() => navigate(sendouQMatchPage(data.match.id))}
/>
-
+
{isLocked || hasReportedMaps ? (
{
...matchUnmapped.groupBravo.members,
].map((m) => m.id);
+ const isStaff = user?.roles.includes("STAFF") ?? false;
+ const isParticipant = Boolean(user && matchUsers.includes(user.id));
+ const canSeeRoomLinks = isStaff || isParticipant;
+
const [privateNotes, roomLinks, anyUserPrefersNoSplatnet, reportedWeapons] =
await Promise.all([
user ? PrivateUserNoteRepository.ownNotes(matchUsers) : undefined,
- RoomLinkRepository.findByUserIds(matchUsers, 3),
+ canSeeRoomLinks ? RoomLinkRepository.findByUserIds(matchUsers, 3) : [],
UserRepository.anyUserPrefersNoSplatnet(matchUsers),
ReportedWeaponRepository.findByMatchId(matchId),
]);
@@ -46,9 +50,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
reportedWeapons,
isOffSeason: Seasons.current() === null,
chatCode: (() => {
- const isStaff = user?.roles.includes("STAFF") ?? false;
- const isParticipant = user && matchUsers.includes(user.id);
-
if (!(isStaff || isParticipant)) return null;
const accessible = chatAccessible({
diff --git a/app/features/tournament-match/TournamentMatchRepository.server.ts b/app/features/tournament-match/TournamentMatchRepository.server.ts
index 3e9311a00..bc37af904 100644
--- a/app/features/tournament-match/TournamentMatchRepository.server.ts
+++ b/app/features/tournament-match/TournamentMatchRepository.server.ts
@@ -106,6 +106,7 @@ export function findResultById(id: number) {
.selectFrom("TournamentMatchGameResult")
.select([
"TournamentMatchGameResult.id",
+ "TournamentMatchGameResult.matchId",
"TournamentMatchGameResult.opponentOnePoints",
"TournamentMatchGameResult.opponentTwoPoints",
"TournamentMatchGameResult.winnerTeamId",
diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
index 527575928..bfaacaf55 100644
--- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
+++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
@@ -33,6 +33,7 @@ import {
parseParams,
parseRequestPayload,
} from "~/utils/remix.server";
+import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { executeRoll } from "../core/executeRoll.server";
import { resolveMapList } from "../core/mapList.server";
@@ -59,6 +60,11 @@ export const action: ActionFunction = async ({ params, request }) => {
const match = notFoundIfFalsy(
await TournamentMatchRepository.findMatchById(matchId),
);
+
+ if (match.tournamentId !== tournamentId) {
+ throw new Response(null, { status: 404 });
+ }
+
const data = await parseRequestPayload({
request,
schema: matchSchema,
@@ -120,6 +126,7 @@ export const action: ActionFunction = async ({ params, request }) => {
let emitTournamentUpdate = false;
let setIsOver = false;
let endedDroppedMatchIds: number[] = [];
+ let followingMatchIds: number[] = [];
switch (data._action) {
case "REPORT_SCORE": {
@@ -198,53 +205,62 @@ export const action: ActionFunction = async ({ params, request }) => {
"Duplicate user in rosters",
);
- sql.transaction(() => {
- manager.update.match({
- id: match.id,
- opponent1: {
- score: scores[0],
- result: setOver && scores[0] > scores[1] ? "win" : undefined,
- },
- opponent2: {
- score: scores[1],
- result: setOver && scores[1] > scores[0] ? "win" : undefined,
- },
- });
-
- const result = insertTournamentMatchGameResult({
- matchId: match.id,
- mode: currentMap.mode,
- stageId: currentMap.stageId,
- reporterId: user.id,
- winnerTeamId: data.winnerTeamId,
- number: data.position + 1,
- source: String(currentMap.source),
- opponentOnePoints: data.points?.[0] ?? null,
- opponentTwoPoints: data.points?.[1] ?? null,
- });
-
- for (const userId of teamOneRoster) {
- insertTournamentMatchGameResultParticipant({
- matchGameResultId: result.id,
- userId,
- tournamentTeamId: match.opponentOne!.id!,
+ try {
+ sql.transaction(() => {
+ manager.update.match({
+ id: match.id,
+ opponent1: {
+ score: scores[0],
+ result: setOver && scores[0] > scores[1] ? "win" : undefined,
+ },
+ opponent2: {
+ score: scores[1],
+ result: setOver && scores[1] > scores[0] ? "win" : undefined,
+ },
});
- }
- for (const userId of teamTwoRoster) {
- insertTournamentMatchGameResultParticipant({
- matchGameResultId: result.id,
- userId,
- tournamentTeamId: match.opponentTwo!.id!,
- });
- }
- if (setOver) {
- endedDroppedMatchIds = endDroppedTeamMatches({
- tournament,
- manager,
+ const result = insertTournamentMatchGameResult({
+ matchId: match.id,
+ mode: currentMap.mode,
+ stageId: currentMap.stageId,
+ reporterId: user.id,
+ winnerTeamId: data.winnerTeamId,
+ number: data.position + 1,
+ source: String(currentMap.source),
+ opponentOnePoints: data.points?.[0] ?? null,
+ opponentTwoPoints: data.points?.[1] ?? null,
});
+
+ for (const userId of teamOneRoster) {
+ insertTournamentMatchGameResultParticipant({
+ matchGameResultId: result.id,
+ userId,
+ tournamentTeamId: match.opponentOne!.id!,
+ });
+ }
+ for (const userId of teamTwoRoster) {
+ insertTournamentMatchGameResultParticipant({
+ matchGameResultId: result.id,
+ userId,
+ tournamentTeamId: match.opponentTwo!.id!,
+ });
+ }
+
+ if (setOver) {
+ endedDroppedMatchIds = endDroppedTeamMatches({
+ tournament,
+ manager,
+ });
+ }
+ })();
+ } catch (error) {
+ // another request already reported this game in the race window,
+ // let their page refresh to pick up the already-recorded result
+ if (errorIsSqliteUniqueConstraintFailure(error)) {
+ return null;
}
- })();
+ throw error;
+ }
emitMatchUpdate = true;
emitTournamentUpdate = true;
@@ -379,6 +395,10 @@ export const action: ActionFunction = async ({ params, request }) => {
data.resultId,
);
errorToastIfFalsy(result, "Result not found");
+ errorToastIfFalsy(
+ result.matchId === matchId,
+ "Result does not belong to this match",
+ );
errorToastIfFalsy(
data.rosters[0].length === tournament.minMembersPerTeam &&
data.rosters[1].length === tournament.minMembersPerTeam,
@@ -557,14 +577,23 @@ export const action: ActionFunction = async ({ params, request }) => {
return "PICK" as const;
})();
- await TournamentRepository.addPickBanEvent({
- authorId: user.id,
- matchId: match.id,
- stageId: isModeAction ? null : data.stageId!,
- mode: isCustomStageBan ? null : (data.mode ?? null),
- number: currentPickBanEvents.length + 1,
- type: eventType,
- });
+ try {
+ await TournamentRepository.addPickBanEvent({
+ authorId: user.id,
+ matchId: match.id,
+ stageId: isModeAction ? null : data.stageId!,
+ mode: isCustomStageBan ? null : (data.mode ?? null),
+ number: currentPickBanEvents.length + 1,
+ type: eventType,
+ });
+ } catch (error) {
+ // another request already recorded this pick/ban in the race window,
+ // let their page refresh to pick up the already-recorded event
+ if (errorIsSqliteUniqueConstraintFailure(error)) {
+ return null;
+ }
+ throw error;
+ }
// Chain roll after action for CUSTOM flow
if (match.roundMaps.pickBan === "CUSTOM" && match.roundMaps.customFlow) {
@@ -636,7 +665,12 @@ export const action: ActionFunction = async ({ params, request }) => {
}
}
- if (lastResult) deleteTournamentMatchGameResultById(lastResult.id);
+ // when the set was force-ended early no extra result was inserted for
+ // the forced win, so the last result is a genuinely played game and must
+ // be kept to avoid desyncing the score from the results
+ if (!endedEarly && lastResult) {
+ deleteTournamentMatchGameResultById(lastResult.id);
+ }
manager.update.match({
id: match.id,
@@ -651,6 +685,12 @@ export const action: ActionFunction = async ({ params, request }) => {
});
})();
+ // the teams advanced into following matches are being pulled back out,
+ // so those "waiting for teams" pages need to revalidate too
+ followingMatchIds = followingMatches.map(
+ (followingMatch) => followingMatch.id,
+ );
+
emitMatchUpdate = true;
emitTournamentUpdate = true;
@@ -824,10 +864,19 @@ export const action: ActionFunction = async ({ params, request }) => {
// update RunningTournaments to make sure sidebar is not showing stale matches at the end
// of the tournament in case the TO is not finalizing the tournament right away
if (setIsOver) {
- await tournamentFromDB({ tournamentId, user });
+ const refreshedTournament = await tournamentFromDB({ tournamentId, user });
+ // the teams that just advanced now populate following matches, so their
+ // "waiting for teams" pages need to revalidate too
+ followingMatchIds = refreshedTournament
+ .followingMatches(match.id)
+ .map((followingMatch) => followingMatch.id);
}
if (emitMatchUpdate) {
+ const otherMatchIdsToRevalidate = Array.from(
+ new Set([...endedDroppedMatchIds, ...followingMatchIds]),
+ ).filter((id) => id !== matchId);
+
ChatSystemMessage.send([
{
room: tournamentMatchWebsocketRoom(matchId),
@@ -835,7 +884,7 @@ export const action: ActionFunction = async ({ params, request }) => {
revalidateOnly: true,
authorUserId: user.id,
},
- ...endedDroppedMatchIds.map((id) => ({
+ ...otherMatchIdsToRevalidate.map((id) => ({
room: tournamentMatchWebsocketRoom(id),
type: "TOURNAMENT_MATCH_UPDATED" as const,
revalidateOnly: true as const,
diff --git a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx
index 5d6479e26..e7f1bda53 100644
--- a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx
+++ b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx
@@ -10,6 +10,8 @@ import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tour
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
+import { useMatch } from "../match-page-context";
+import { UndoReportButton } from "./TournamentMatchActionTab";
type FromIndicator = NonNullable;
@@ -25,6 +27,7 @@ export function TournamentMatchActionPickBanTab({
const user = useUser();
const tournament = useTournament();
const fetcher = useFetcher();
+ const { scoreSum } = useMatch();
const pickerTeamId = turnOfResult.teamId;
const pickingTeam = teams.find((team) => team.id === pickerTeamId)!;
@@ -96,6 +99,7 @@ export function TournamentMatchActionPickBanTab({
type={sharedActionType}
isSubmitting={fetcher.state !== "idle"}
waitingFor={canPickBan ? undefined : pickingTeam.name}
+ actionButtons={}
onSubmit={({ map }) => {
fetcher.submit(
{
diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx
index 118fe1c0d..e2c00bef6 100644
--- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx
+++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx
@@ -22,11 +22,9 @@ export function TournamentMatchActionTab({
data: TournamentMatchLoaderData;
ownTeamId: number | null;
}) {
- const { t } = useTranslation(["q"]);
const tournament = useTournament();
const user = useUser();
const reportFetcher = useFetcher();
- const undoFetcher = useFetcher();
const {
teams: [teamOne, teamTwo],
scores,
@@ -40,9 +38,12 @@ export function TournamentMatchActionTab({
weaponReportingOpen: tournament.weaponReportingOpen,
});
+ // during pick/ban there is no current map to report, but a wrongly reported
+ // score from a previous game can still be undone
if (!currentMap) {
return (
+ {scoreSum > 0 ? : null}
{weaponReport ? : null}
);
@@ -123,27 +124,7 @@ export function TournamentMatchActionTab({
{ method: "post" },
);
}}
- actionButtons={
- }
- isPending={undoFetcher.state !== "idle"}
- isDisabled={scoreSum === 0}
- onPress={() => {
- undoFetcher.submit(
- {
- _action: "UNDO_REPORT_SCORE",
- position: String(scoreSum - 1),
- },
- { method: "post" },
- );
- }}
- testId="undo-score-button"
- >
- {t("q:match.undoReport")}
-
- }
+ actionButtons={}
secondaryAction={
weaponReport ? : null
}
@@ -151,6 +132,33 @@ export function TournamentMatchActionTab({
);
}
+export function UndoReportButton({ scoreSum }: { scoreSum: number }) {
+ const { t } = useTranslation(["q"]);
+ const undoFetcher = useFetcher();
+
+ return (
+ }
+ isPending={undoFetcher.state !== "idle"}
+ isDisabled={scoreSum === 0}
+ onPress={() => {
+ undoFetcher.submit(
+ {
+ _action: "UNDO_REPORT_SCORE",
+ position: String(scoreSum - 1),
+ },
+ { method: "post" },
+ );
+ }}
+ testId="undo-score-button"
+ >
+ {t("q:match.undoReport")}
+
+ );
+}
+
function useTournamentWeaponReport({
data,
viewerUserId,
diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx
index e006c2ce4..f026c2d7b 100644
--- a/app/features/tournament-match/components/TournamentMatchBanner.tsx
+++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx
@@ -1,6 +1,7 @@
import { differenceInMinutes } from "date-fns";
import {
Flag,
+ Gavel,
Hourglass,
Lock,
MousePointerClick,
@@ -96,7 +97,27 @@ export function TournamentMatchBanner({
return (
- {leagueRoundLocked ? (
+ {data.matchIsOver ? (
+ droppedOutTeamName ? (
+ }
+ header={t("tournament:match.droppedOut.header")}
+ subtitle={t("tournament:match.droppedOut.subtitle", {
+ team: droppedOutTeamName,
+ })}
+ />
+ ) : data.endedEarly ? (
+ }
+ header={t("tournament:match.endedEarly.header")}
+ subtitle={t("tournament:match.endedEarly.subtitle")}
+ />
+ ) : (
+ result.stageId)}
+ />
+ )
+ ) : leagueRoundLocked ? (
}
header={t("tournament:match.leagueLocked.header")}
@@ -134,20 +155,6 @@ export function TournamentMatchBanner({
joinViaQr={joinViaQr}
testId="active-roster-needed-text"
/>
- ) : data.matchIsOver ? (
- droppedOutTeamName ? (
- }
- header={t("tournament:match.droppedOut.header")}
- subtitle={t("tournament:match.droppedOut.subtitle", {
- team: droppedOutTeamName,
- })}
- />
- ) : (
- result.stageId)}
- />
- )
) : pickBanBanner ? (
) : null}
;
+}): number | null {
+ const lastGameStartedAt =
+ data.results.at(-1)?.createdAt ?? data.match.startedAt;
+
const opponentOneId = data.match.opponentOne?.id;
const opponentTwoId = data.match.opponentTwo?.id;
- if (!opponentOneId || !opponentTwoId) return 0;
- if (!data.match.roundMaps?.pickBan) return 0;
+ if (!opponentOneId || !opponentTwoId) return lastGameStartedAt;
+ if (!data.match.roundMaps?.pickBan) return lastGameStartedAt;
const teamOne = tournament.teamById(opponentOneId);
const teamTwo = tournament.teamById(opponentTwoId);
- if (!teamOne || !teamTwo) return 0;
+ if (!teamOne || !teamTwo) return lastGameStartedAt;
const teams: [PickBan.PickBanTeam, PickBan.PickBanTeam] = [
{ id: teamOne.id, seed: teamOne.seed },
@@ -353,21 +385,17 @@ function resolveCurrentMinutes({
mapList: data.mapList,
pickBanEventCount: data.pickBanEventCount,
});
- if (!currentTurn) return 0;
+ if (!currentTurn) return lastGameStartedAt;
- const sessionStart = PickBan.currentTurnSessionStartedAt({
- currentTurn,
- events: data.pickBanEvents,
- results: data.results,
- matchStartedAt: data.match.startedAt,
- maps: data.match.roundMaps,
- teams,
- });
- if (sessionStart == null) return 0;
-
- return Math.max(
- 0,
- differenceInMinutes(currentTime, databaseTimestampToDate(sessionStart)),
+ return (
+ PickBan.currentTurnSessionStartedAt({
+ currentTurn,
+ events: data.pickBanEvents,
+ results: data.results,
+ matchStartedAt: data.match.startedAt,
+ maps: data.match.roundMaps,
+ teams,
+ }) ?? lastGameStartedAt
);
}
@@ -458,30 +486,23 @@ function resolveDroppedOutTeamName({
: null;
if (!droppedOutId) return null;
- return tournament.teamById(droppedOutId)?.name ?? null;
+ const team = tournament.teamById(droppedOutId);
+ if (!team?.droppedOut) return null;
+
+ return team.name;
}
function resolveBannerGames({
data,
- opponentOneId,
}: {
data: TournamentMatchLoaderData;
- opponentOneId: number | null | undefined;
-}): Array<{ mode: ModeShort | null; winner?: "ALPHA" | "BRAVO" }> {
+}): Array<{ mode: ModeShort | null }> {
const playedAndScheduled =
- data.mapList?.map((map, i) => {
- const result = data.results.at(i);
- const winner = result
- ? result.winnerTeamId === opponentOneId
- ? ("ALPHA" as const)
- : ("BRAVO" as const)
- : undefined;
-
- return {
+ data.mapList
+ ?.filter((map) => !map.bannedByTournamentTeamId)
+ .map((map) => ({
mode: map.mode as ModeShort | null,
- winner,
- };
- }) ?? [];
+ })) ?? [];
if (data.matchIsOver) return playedAndScheduled;
diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx
index 6f73c5fe8..92329cb78 100644
--- a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx
+++ b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx
@@ -2,6 +2,7 @@ import { useLoaderData } from "react-router";
import { containerClassName } from "~/components/Main";
import { MatchPage } from "~/components/match-page/MatchPage";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
+import { useTournament } from "~/features/tournament/routes/to.$id";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { action } from "../actions/to.$id.matches.$mid.server";
import { TournamentMatchBanner } from "../components/TournamentMatchBanner";
@@ -19,10 +20,11 @@ export const handle: SendouRouteHandle = {
export default function TournamentMatchPage() {
const data = useLoaderData();
+ const tournament = useTournament();
useWebsocketRevalidation(
tournamentMatchWebsocketRoom(data.match.id),
- !data.matchIsOver,
+ !tournament.ctx.isFinalized,
);
return (
diff --git a/locales/da/tournament.json b/locales/da/tournament.json
index 42b53de36..bae20da91 100644
--- a/locales/da/tournament.json
+++ b/locales/da/tournament.json
@@ -162,6 +162,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/de/tournament.json b/locales/de/tournament.json
index eb17c6cf8..fc4a2cc50 100644
--- a/locales/de/tournament.json
+++ b/locales/de/tournament.json
@@ -162,6 +162,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/en/tournament.json b/locales/en/tournament.json
index 89fae133b..122fb92d1 100644
--- a/locales/en/tournament.json
+++ b/locales/en/tournament.json
@@ -162,6 +162,8 @@
"match.activeRosterMissing.subtitle": "Waiting on {{teams}}",
"match.droppedOut.header": "Team dropped out",
"match.droppedOut.subtitle": "{{team}} forfeited the match",
+ "match.endedEarly.header": "Match ended early",
+ "match.endedEarly.subtitle": "Staff ended the match before it was decided",
"match.leagueLocked.header": "Waiting for league round to start",
"match.leagueLocked.subtitle": "Round playable from {{date}} onwards",
"match.locked.header": "Match locked to be casted",
diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json
index 48204c11c..32b977a70 100644
--- a/locales/es-ES/tournament.json
+++ b/locales/es-ES/tournament.json
@@ -164,6 +164,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json
index 4b44cfb3d..b0db2be63 100644
--- a/locales/es-US/tournament.json
+++ b/locales/es-US/tournament.json
@@ -164,6 +164,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json
index ff04da12a..1a92b4379 100644
--- a/locales/fr-CA/tournament.json
+++ b/locales/fr-CA/tournament.json
@@ -164,6 +164,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json
index 96ee68578..fc5913fdc 100644
--- a/locales/fr-EU/tournament.json
+++ b/locales/fr-EU/tournament.json
@@ -164,6 +164,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/he/tournament.json b/locales/he/tournament.json
index 56825c0ac..24d6ccada 100644
--- a/locales/he/tournament.json
+++ b/locales/he/tournament.json
@@ -164,6 +164,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/it/tournament.json b/locales/it/tournament.json
index 97ac3ab1c..0c333dd38 100644
--- a/locales/it/tournament.json
+++ b/locales/it/tournament.json
@@ -164,6 +164,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json
index 65f001d7a..c4a4a0a97 100644
--- a/locales/ja/tournament.json
+++ b/locales/ja/tournament.json
@@ -158,6 +158,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json
index 8d0eb48cc..c0ba44f52 100644
--- a/locales/ko/tournament.json
+++ b/locales/ko/tournament.json
@@ -158,6 +158,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json
index 2a7fa236b..d8fad47c5 100644
--- a/locales/nl/tournament.json
+++ b/locales/nl/tournament.json
@@ -162,6 +162,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json
index 977284517..f9c9a75ac 100644
--- a/locales/pl/tournament.json
+++ b/locales/pl/tournament.json
@@ -166,6 +166,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json
index 58d58dbb4..3985704dc 100644
--- a/locales/pt-BR/tournament.json
+++ b/locales/pt-BR/tournament.json
@@ -164,6 +164,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json
index ba7769796..014a7a8fc 100644
--- a/locales/ru/tournament.json
+++ b/locales/ru/tournament.json
@@ -166,6 +166,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",
diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json
index e5b83fb56..a5466f677 100644
--- a/locales/zh/tournament.json
+++ b/locales/zh/tournament.json
@@ -158,6 +158,8 @@
"match.activeRosterMissing.subtitle": "",
"match.droppedOut.header": "",
"match.droppedOut.subtitle": "",
+ "match.endedEarly.header": "",
+ "match.endedEarly.subtitle": "",
"match.leagueLocked.header": "",
"match.leagueLocked.subtitle": "",
"match.locked.header": "",