From 646cc048994866a44cab0094083da01c8e2b0b83 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Tue, 21 Apr 2026 21:10:57 +0300
Subject: [PATCH] Wire up tournament join tab
---
.../tournament-bracket-schemas.server.ts | 4 +
.../actions/to.$id.matches.$mid.server.ts | 5 ++
.../components/TournamentMatchTabs.tsx | 84 +++++++++++++++++--
.../loaders/to.$id.matches.$mid.server.ts | 14 ++++
4 files changed, 100 insertions(+), 7 deletions(-)
diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
index 93b6680d4..cbf818337 100644
--- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
+++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
@@ -101,6 +101,10 @@ export const matchSchema = z.union([
_action: _action("END_SET"),
winnerTeamId: z.preprocess(nullLiteraltoNull, id.nullable()),
}),
+ // xxx: one central API for confirm room?
+ z.object({
+ _action: _action("CONFIRM_ROOM"),
+ }),
]);
export const bracketIdx = z.coerce.number().int().min(0).max(100);
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 2a97992a6..b465f0b01 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
@@ -3,6 +3,7 @@ import { sql } from "~/db/sql";
import { TournamentMatchStatus } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
+import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server";
@@ -728,6 +729,10 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
+ case "CONFIRM_ROOM": {
+ await RoomLinkRepository.refreshTimestamp(user.id);
+ break;
+ }
default: {
assertUnreachable(data);
}
diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx
index 71b7c8455..71ccbc736 100644
--- a/app/features/tournament-match/components/TournamentMatchTabs.tsx
+++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx
@@ -1,3 +1,4 @@
+import { differenceInMinutes } from "date-fns";
import { useFetcher } from "react-router";
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
@@ -6,9 +7,14 @@ import { MatchTabs } from "~/components/match-page/MatchTabs";
import type { TimelineMap } from "~/components/match-page/MatchTimeline";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
-import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
+import {
+ groupNumberToLetters,
+ tournamentTeamToActiveRosterUserIds,
+} from "~/features/tournament-bracket/tournament-bracket-utils";
+import { databaseTimestampToDate } from "~/utils/dates";
import { tournamentTeamPage } from "~/utils/urls";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
+import { resolveHostingTeam, resolveRoomPass } from "../tournament-match-utils";
import { TournamentMatchActionTab } from "./TournamentMatchActionTab";
export function TournamentMatchTabs({
@@ -62,7 +68,7 @@ export function TournamentMatchTabs({
maps={resolveTimelineMaps(data, opponentOneId, opponentTwoId)}
/>
) : null}
- {tabs.includes("join") ? : null}
+ {tabs.includes("join") ? : null}
{tabs.includes("action") && currentMap ? (
b.type === "round_robin",
+ );
+ const bracketIdx = tournament.brackets.findIndex((b) =>
+ b.data.match.some((m) => m.id === data.match.id),
+ );
+ const bracket = tournament.brackets[bracketIdx];
+ const bracketMatch = bracket?.data.match.find((m) => m.id === data.match.id);
+ const group = bracket?.data.group.find(
+ (g) => g.id === bracketMatch?.group_id,
+ );
+
+ const poolCode = tournament.resolvePoolCode({
+ hostingTeamId: hostingTeam.id,
+ groupLetters:
+ group && bracket?.type === "round_robin"
+ ? groupNumberToLetters(group.number)
+ : undefined,
+ bracketNumber:
+ hasRoundRobin && bracket?.type !== "round_robin"
+ ? bracketIdx + 1
+ : undefined,
+ });
+
+ // xxx: maybe some shared util?
+ const freshnessCutoff = data.match.startedAt ?? 0;
+ const validRoomLink = data.roomLinks.find(
+ (rl) => rl.refreshedAt >= freshnessCutoff,
+ );
+ const ownStaleRoomLink = validRoomLink
+ ? undefined
+ : data.roomLinks.find((rl) => rl.userId === user?.id);
+ const activeRoomLink = validRoomLink ?? ownStaleRoomLink;
+ const isStale = activeRoomLink ? !validRoomLink : undefined;
+ const staleMinutesAgo = ownStaleRoomLink
+ ? differenceInMinutes(
+ new Date(),
+ databaseTimestampToDate(ownStaleRoomLink.refreshedAt),
+ )
+ : 0;
+ const roomLinkUsername = activeRoomLink
+ ? data.match.players.find((p) => p.id === activeRoomLink.userId)?.username
+ : undefined;
+
return (
{
+ confirmFetcher.submit({ _action: "CONFIRM_ROOM" }, { method: "post" });
+ }}
+ isConfirming={confirmFetcher.state !== "idle"}
+ pool={`${poolCode.prefix}${poolCode.suffix}`}
+ pass={resolveRoomPass(hostingTeam.id)}
+ showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
/>
);
}
diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
index 1ffade3d5..37753ad09 100644
--- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
+++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
@@ -3,6 +3,7 @@ import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { chatAccessible } from "~/features/chat/chat-utils";
+import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
@@ -176,6 +177,17 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const visibleChatCode =
shouldSeeChat && !chatCodeExpired ? match.chatCode : undefined;
+ // xxx: optimization, can be skipped if user can't join anyway
+ const [roomLinks, anyUserPrefersNoSplatnet] = matchIsOver
+ ? ([[], false] as const)
+ : await Promise.all([
+ RoomLinkRepository.findByUserIds(
+ match.players.map((p) => p.id),
+ 3,
+ ),
+ UserRepository.anyUserPrefersNoSplatnet(match.players.map((p) => p.id)),
+ ]);
+
return {
match: shouldSeeChat ? match : { ...match, chatCode: undefined },
results,
@@ -184,6 +196,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
endedEarly,
noScreen,
chatCode: visibleChatCode,
+ roomLinks,
+ anyUserPrefersNoSplatnet,
pickBanEventCount: pickBanEvents.length,
pickBanEvents: pickBanEvents.map((e) => ({
type: e.type,