mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Several smaller match page fixes and tweaks (notably, fix timer)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
|
||||
.iconBannerSubtitle {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-low);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.iconBannerBottomRight {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -106,6 +106,7 @@ export function findResultById(id: number) {
|
||||
.selectFrom("TournamentMatchGameResult")
|
||||
.select([
|
||||
"TournamentMatchGameResult.id",
|
||||
"TournamentMatchGameResult.matchId",
|
||||
"TournamentMatchGameResult.opponentOnePoints",
|
||||
"TournamentMatchGameResult.opponentTwoPoints",
|
||||
"TournamentMatchGameResult.winnerTeamId",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user