diff --git a/app/components/elements/Tabs.module.css b/app/components/elements/Tabs.module.css index f0b9b802b..0e4c429cf 100644 --- a/app/components/elements/Tabs.module.css +++ b/app/components/elements/Tabs.module.css @@ -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; diff --git a/app/components/elements/Tabs.tsx b/app/components/elements/Tabs.tsx index 5304e5ef4..8529b0575 100644 --- a/app/components/elements/Tabs.tsx +++ b/app/components/elements/Tabs.tsx @@ -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 (
@@ -100,6 +109,7 @@ export function SendouTab({ icon, children, number, ...rest }: SendouTabProps) { {typeof number === "number" && number !== 0 && ( {number} )} + {alert ? : null}
); diff --git a/app/components/match-page/MatchActionPickBanTab.module.css b/app/components/match-page/MatchActionPickBanTab.module.css index d8f0b7194..bcf773d08 100644 --- a/app/components/match-page/MatchActionPickBanTab.module.css +++ b/app/components/match-page/MatchActionPickBanTab.module.css @@ -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; diff --git a/app/components/match-page/MatchActionPickBanTab.tsx b/app/components/match-page/MatchActionPickBanTab.tsx index 7e7833c2b..3f8c3a240 100644 --- a/app/components/match-page/MatchActionPickBanTab.tsx +++ b/app/components/match-page/MatchActionPickBanTab.tsx @@ -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({
{t(titleKey)}
+ {actionButtons ? ( +
{actionButtons}
+ ) : null}
{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": "",