Several smaller match page fixes and tweaks (notably, fix timer)

This commit is contained in:
Kalle
2026-06-11 21:28:35 +03:00
parent 544fe4f66c
commit 5a80c0a7fa
37 changed files with 338 additions and 155 deletions

View File

@@ -95,6 +95,12 @@
margin-inline-start: var(--s-2);
}
.tabAlert {
margin-inline-start: var(--s-1);
margin-inline-end: 0 !important;
color: var(--color-warning);
}
.sticky {
position: sticky;
top: 47px;

View File

@@ -1,4 +1,5 @@
import clsx from "clsx";
import { TriangleAlert } from "lucide-react";
import {
Tab,
TabList,
@@ -88,10 +89,18 @@ export function SendouTabs({
interface SendouTabProps extends TabProps {
icon?: React.ReactNode;
number?: number;
/** Render a warning-colored alert icon to draw attention to this tab. */
alert?: boolean;
children?: React.ReactNode;
}
export function SendouTab({ icon, children, number, ...rest }: SendouTabProps) {
export function SendouTab({
icon,
children,
number,
alert,
...rest
}: SendouTabProps) {
return (
<Tab className={styles.tabContainer} {...rest}>
<div className={clsx(buttonStyles.button, styles.tabButton)}>
@@ -100,6 +109,7 @@ export function SendouTab({ icon, children, number, ...rest }: SendouTabProps) {
{typeof number === "number" && number !== 0 && (
<span className={styles.tabNumber}>{number}</span>
)}
{alert ? <TriangleAlert className={styles.tabAlert} /> : null}
</div>
</Tab>
);

View File

@@ -3,12 +3,13 @@
grid-template-columns: 1fr;
grid-template-areas:
"header"
"actions"
"options"
"prompt"
"submit";
justify-items: center;
align-items: center;
gap: var(--s-6);
gap: var(--s-5);
container-type: inline-size;
}
@@ -20,6 +21,13 @@
text-box: trim-start cap alphabetic;
}
.actionButtons {
grid-area: actions;
display: flex;
gap: var(--s-6);
margin-block-start: calc(-1 * var(--s-4));
}
.options {
grid-area: options;
display: flex;
@@ -32,7 +40,7 @@
grid-area: prompt;
margin: 0;
font-size: var(--font-sm);
color: var(--color-text-lighter);
color: var(--color-text-high);
text-align: center;
}
@@ -54,7 +62,7 @@
grid-row: prompt-start / submit-end;
margin: 0;
font-size: var(--font-sm);
color: var(--color-text-lighter);
color: var(--color-text-high);
text-align: center;
}
@@ -116,7 +124,7 @@
border: none;
background-color: transparent;
transition:
filter,
filter 0.2s,
opacity 0.2s;
border-radius: var(--radius-box);
cursor: pointer;

View File

@@ -30,6 +30,7 @@ interface MatchActionPickBanTabProps {
type: "PICK" | "BAN";
onSubmit?: (data: PickBanSubmission) => void;
isSubmitting?: boolean;
actionButtons?: React.ReactNode;
secondaryAction?: React.ReactNode;
waitingFor?: string;
}
@@ -39,6 +40,7 @@ export function MatchActionPickBanTab({
type,
onSubmit,
isSubmitting,
actionButtons,
secondaryAction,
waitingFor,
}: MatchActionPickBanTabProps) {
@@ -86,6 +88,9 @@ export function MatchActionPickBanTab({
<SendouTabPanel id={TAB_KEYS.ACTION}>
<div className={styles.root}>
<div className={styles.title}>{t(titleKey)}</div>
{actionButtons ? (
<div className={styles.actionButtons}>{actionButtons}</div>
) : null}
<div className={styles.options}>
{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" ? (

View File

@@ -163,7 +163,7 @@
.iconBannerSubtitle {
font-size: var(--font-xs);
color: var(--color-text-low);
color: var(--color-text-high);
}
.iconBannerBottomRight {

View File

@@ -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"}
>
<Icon
size={imgSize}

View File

@@ -32,7 +32,7 @@
background-color: transparent;
padding: calc(var(--s-1) - 1px);
border: 1px dashed var(--color-bg-higher);
color: var(--color-text-low);
color: var(--color-text-high);
display: flex;
align-items: center;
justify-content: center;

View File

@@ -8,7 +8,7 @@ import { ModeImage } from "../Image";
import styles from "./MatchBannerBottomRow.module.css";
interface MatchBannerBottomRowProps {
games: Array<{ mode: ModeShort | null; winner?: "ALPHA" | "BRAVO" }>;
games: Array<{ mode: ModeShort | null }>;
activeRosters: {
alpha: CommonUser[] | null;
bravo: CommonUser[] | null;

View File

@@ -347,7 +347,11 @@ function MemberTierPopover({
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" className={styles.tierBadge}>
<SendouButton
variant="minimal"
className={styles.tierBadge}
aria-label="Tier"
>
{tier === "CALCULATING" ? (
<Image
path={tierImageUrl("CALCULATING")}

View File

@@ -17,6 +17,8 @@ type MatchTabsKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];
interface MatchTabsProps {
children: React.ReactNode;
tabs: Array<MatchTabsKey>;
/** Tabs that should show a warning-colored alert icon to draw attention. */
alertTabs?: Array<MatchTabsKey>;
}
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) {
>
<SendouTabList>
{tabs.map((tab) => (
<SendouTab key={tab} id={tab} icon={TAB_ICONS[tab]}>
<SendouTab
key={tab}
id={tab}
icon={TAB_ICONS[tab]}
alert={alertTabs?.includes(tab)}
>
{t(TAB_TRANSLATION_KEYS[tab])}
</SendouTab>
))}

View File

@@ -98,11 +98,7 @@ export default function MatchPageTestRoute() {
screenLegal={false}
/>
<MatchBannerBottomRow
games={[
{ mode: "SZ", winner: "ALPHA" },
{ mode: "TC", winner: "BRAVO" },
{ mode: "RM", winner: "ALPHA" },
]}
games={[{ mode: "SZ" }, { mode: "TC" }, { mode: "RM" }]}
activeRosters={{
alpha: [
{

View File

@@ -588,6 +588,10 @@ export async function cancelMatch({
const match = await findById(matchId);
invariant(match, "Match not found");
if (match.isLocked) {
return { status: "CANT_CANCEL", shouldRefreshCaches: false };
}
if (isAdminReport) {
await db.transaction().execute(async (trx) => {
await trx
@@ -1070,6 +1074,7 @@ async function finalizeMatch({
.set({
confirmedAt: dateToDatabaseTimestamp(new Date()),
confirmedByUserId,
cancelRequestedByUserId: null,
})
.where("id", "=", match.id)
.execute();

View File

@@ -46,12 +46,6 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
<MatchBannerBottomRow
games={data.match.mapList.map((map) => ({
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,

View File

@@ -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))}
/>
<MatchTabs tabs={tabs}>
<MatchTabs tabs={tabs} alertTabs={alertTabs}>
{isLocked || hasReportedMaps ? (
<MatchResultTab
teams={resolveTimelineTeams(data.match, t)}

View File

@@ -29,10 +29,14 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
...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({

View File

@@ -106,6 +106,7 @@ export function findResultById(id: number) {
.selectFrom("TournamentMatchGameResult")
.select([
"TournamentMatchGameResult.id",
"TournamentMatchGameResult.matchId",
"TournamentMatchGameResult.opponentOnePoints",
"TournamentMatchGameResult.opponentTwoPoints",
"TournamentMatchGameResult.winnerTeamId",

View File

@@ -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,

View File

@@ -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<PickBanMapOption["picker"]>;
@@ -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={<UndoReportButton scoreSum={scoreSum} />}
onSubmit={({ map }) => {
fetcher.submit(
{

View File

@@ -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 (
<SendouTabPanel id={TAB_KEYS.ACTION}>
{scoreSum > 0 ? <UndoReportButton scoreSum={scoreSum} /> : null}
{weaponReport ? <WeaponReporter {...weaponReport} standalone /> : null}
</SendouTabPanel>
);
@@ -123,27 +124,7 @@ export function TournamentMatchActionTab({
{ method: "post" },
);
}}
actionButtons={
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
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")}
</SendouButton>
}
actionButtons={<UndoReportButton scoreSum={scoreSum} />}
secondaryAction={
weaponReport ? <WeaponReporter {...weaponReport} /> : null
}
@@ -151,6 +132,33 @@ export function TournamentMatchActionTab({
);
}
export function UndoReportButton({ scoreSum }: { scoreSum: number }) {
const { t } = useTranslation(["q"]);
const undoFetcher = useFetcher();
return (
<SendouButton
variant="minimal-destructive"
size="miniscule"
icon={<Undo2 size={16} />}
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")}
</SendouButton>
);
}
function useTournamentWeaponReport({
data,
viewerUserId,

View File

@@ -1,6 +1,7 @@
import { differenceInMinutes } from "date-fns";
import {
Flag,
Gavel,
Hourglass,
Lock,
MousePointerClick,
@@ -96,7 +97,27 @@ export function TournamentMatchBanner({
return (
<MatchBannerContainer>
<TournamentMatchBannerTopRow data={data} />
{leagueRoundLocked ? (
{data.matchIsOver ? (
droppedOutTeamName ? (
<IconBanner
icon={<Flag size={32} />}
header={t("tournament:match.droppedOut.header")}
subtitle={t("tournament:match.droppedOut.subtitle", {
team: droppedOutTeamName,
})}
/>
) : data.endedEarly ? (
<IconBanner
icon={<Gavel size={32} />}
header={t("tournament:match.endedEarly.header")}
subtitle={t("tournament:match.endedEarly.subtitle")}
/>
) : (
<MultiMatchBanner
stageIds={data.results.map((result) => result.stageId)}
/>
)
) : leagueRoundLocked ? (
<IconBanner
icon={<Lock size={32} />}
header={t("tournament:match.leagueLocked.header")}
@@ -134,20 +155,6 @@ export function TournamentMatchBanner({
joinViaQr={joinViaQr}
testId="active-roster-needed-text"
/>
) : data.matchIsOver ? (
droppedOutTeamName ? (
<IconBanner
icon={<Flag size={32} />}
header={t("tournament:match.droppedOut.header")}
subtitle={t("tournament:match.droppedOut.subtitle", {
team: droppedOutTeamName,
})}
/>
) : (
<MultiMatchBanner
stageIds={data.results.map((result) => result.stageId)}
/>
)
) : pickBanBanner ? (
<IconBanner
icon={pickBanBanner.icon}
@@ -178,7 +185,7 @@ export function TournamentMatchBanner({
</MatchBanner>
) : null}
<MatchBannerBottomRow
games={resolveBannerGames({ data, opponentOneId: opponentOne?.id })}
games={resolveBannerGames({ data })}
activeRosters={
opponentOne?.id && opponentTwo?.id
? {
@@ -332,14 +339,39 @@ function resolveCurrentMinutes({
}): number {
if (data.matchIsOver) return 0;
const sessionStart = resolveCurrentSessionStartedAt({ data, tournament });
if (typeof sessionStart !== "number") return 0;
return Math.max(
0,
differenceInMinutes(currentTime, databaseTimestampToDate(sessionStart)),
);
}
/**
* Resolves the database timestamp that the current "session" (the thing the
* sub-timer counts up from) started at. For pick/ban matches this is the start
* of the current pick/ban turn, otherwise it is the time the most recently
* reported game finished, falling back to the match start.
*/
function resolveCurrentSessionStartedAt({
data,
tournament,
}: {
data: TournamentMatchLoaderData;
tournament: ReturnType<typeof useTournament>;
}): 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;

View File

@@ -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<typeof loader>();
const tournament = useTournament();
useWebsocketRevalidation(
tournamentMatchWebsocketRoom(data.match.id),
!data.matchIsOver,
!tournament.ctx.isFinalized,
);
return (

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",