Wire up tournament join tab

This commit is contained in:
Kalle 2026-04-21 21:10:57 +03:00
parent aec410c348
commit 646cc04899
4 changed files with 100 additions and 7 deletions

View File

@ -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);

View File

@ -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);
}

View File

@ -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") ? <TournamentMatchJoinTab /> : null}
{tabs.includes("join") ? <TournamentMatchJoinTab data={data} /> : null}
<TournamentMatchRosterTab data={data} />
{tabs.includes("action") && currentMap ? (
<TournamentMatchActionTab
@ -144,13 +150,77 @@ function resolveTimelineMaps(
});
}
function TournamentMatchJoinTab() {
function TournamentMatchJoinTab({ data }: { data: TournamentMatchLoaderData }) {
const tournament = useTournament();
const user = useUser();
const confirmFetcher = useFetcher();
const teamOne = tournament.teamById(data.match.opponentOne!.id!)!;
const teamTwo = tournament.teamById(data.match.opponentTwo!.id!)!;
const hostingTeam = resolveHostingTeam([teamOne, teamTwo]);
const hasRoundRobin = tournament.brackets.some(
(b) => 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 (
<MatchJoinTab
joinLink="https://app.nintendo.net/private_battle/abc123"
pool="SQ7"
pass="8430"
showNoSplatnetAlert
joinLink={activeRoomLink?.url}
hostedBy={roomLinkUsername ?? hostingTeam.name}
isStale={isStale}
staleMinutesAgo={staleMinutesAgo}
refreshedAt={
validRoomLink
? databaseTimestampToDate(validRoomLink.refreshedAt)
: undefined
}
onConfirmRoom={() => {
confirmFetcher.submit({ _action: "CONFIRM_ROOM" }, { method: "post" });
}}
isConfirming={confirmFetcher.state !== "idle"}
pool={`${poolCode.prefix}${poolCode.suffix}`}
pass={resolveRoomPass(hostingTeam.id)}
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
/>
);
}

View File

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