diff --git a/AGENTS.md b/AGENTS.md index 93a155ec0..161dc7354 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ - one file containing React code should have a matching CSS module file e.g. `Component.tsx` should have a file with the same root name i.e. `Component.module.css` - clsx library is used for conditional class names - prefer using [CSS variables](./app/styles/vars.css) for theming +- for any CSS variable used, make sure it is defined either locally or in the `vars.css` file - for simple styling, prefer [utility classes](./app/styles/utils.css) over creating a new class - use CSS nesting with the `&` selector to group related selectors (pseudo-classes, pseudo-elements, child selectors, attribute selectors) under their parent instead of repeating the parent selector diff --git a/app/components/match-page/MatchActionTab.tsx b/app/components/match-page/MatchActionTab.tsx index 3e2b6adb4..a6c9c37a0 100644 --- a/app/components/match-page/MatchActionTab.tsx +++ b/app/components/match-page/MatchActionTab.tsx @@ -111,7 +111,7 @@ export function MatchActionTab({ ) : null} { const selectedId = Number(value); setWinnerId(selectedId); diff --git a/app/components/match-page/MatchTabs.tsx b/app/components/match-page/MatchTabs.tsx index 283d88961..ba8fcea75 100644 --- a/app/components/match-page/MatchTabs.tsx +++ b/app/components/match-page/MatchTabs.tsx @@ -1,4 +1,4 @@ -import { DoorOpen, ScrollText, Swords, Tally5, Users } from "lucide-react"; +import { DoorOpen, Key, ScrollText, Swords, Tally5, Users } from "lucide-react"; import type * as React from "react"; import { useSearchParams } from "react-router"; import invariant from "~/utils/invariant"; @@ -19,6 +19,7 @@ export const TAB_KEYS = { ACTION: "action", JOIN: "join", RESULT: "result", + ADMIN: "admin", } as const; const TAB_ICONS: Record = { @@ -27,6 +28,7 @@ const TAB_ICONS: Record = { action: , join: , result: , + admin: , }; const TAB_LABELS: Record = { @@ -35,6 +37,7 @@ const TAB_LABELS: Record = { action: "Action", join: "Join", result: "Result", + admin: "Admin", }; export function MatchTabs({ children, tabs }: MatchTabsProps) { @@ -49,7 +52,13 @@ export function MatchTabs({ children, tabs }: MatchTabsProps) { - setSearchParams({ [TAB_KEY]: key as string }) + setSearchParams( + { [TAB_KEY]: key as string }, + { + preventScrollReset: true, + unstable_defaultShouldRevalidate: false, + }, + ) } disappearing={false} > diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx index 0554e2161..ba3fdab91 100644 --- a/app/components/match-page/MatchTimeline.tsx +++ b/app/components/match-page/MatchTimeline.tsx @@ -16,6 +16,7 @@ import { SendouButton } from "../elements/Button"; import { SendouPopover } from "../elements/Popover"; import { ModeImage, StageImage } from "../Image"; import styles from "./MatchTimeline.module.css"; +import { type InferredSubstitution, inferSubstitutions } from "./utils"; import { WeaponPool } from "./WeaponPool"; // xxx: timeline also for a set thats still in progress? instead of the separate pick ban tab @@ -71,7 +72,6 @@ export interface MatchTimelineProps { compact?: boolean; } -// xxx: need to show Pick/Bans somewhere, on tab? export function MatchTimeline({ teams, score, @@ -87,7 +87,7 @@ export function MatchTimeline({ : maps.map((map, i) => { const previousMap = maps[i - 1]; const substitutions = previousMap - ? inferSubstitutions(previousMap, map) + ? inferSubstitutions(previousMap.rosters, map.rosters) : []; return ( @@ -106,40 +106,6 @@ export function MatchTimeline({ ); } -interface InferredSubstitution { - side: MatchSide; - playerOut: CommonUser; - playerIn: CommonUser; -} - -// xxx: unit test -function inferSubstitutions( - previousMap: TimelineMap, - currentMap: TimelineMap, -): InferredSubstitution[] { - const result: InferredSubstitution[] = []; - - for (const side of ["alpha", "bravo"] as const) { - const prevIds = new Set(previousMap.rosters[side].map((u) => u.id)); - const currIds = new Set(currentMap.rosters[side].map((u) => u.id)); - - const out = previousMap.rosters[side].filter((u) => !currIds.has(u.id)); - const inn = currentMap.rosters[side].filter((u) => !prevIds.has(u.id)); - - for (let i = 0; i < Math.max(out.length, inn.length); i++) { - if (out[i] && inn[i]) { - result.push({ - side: side === "alpha" ? "ALPHA" : "BRAVO", - playerOut: out[i], - playerIn: inn[i], - }); - } - } - } - - return result; -} - function TimelineHeader({ teams, score, diff --git a/app/components/match-page/utils.test.ts b/app/components/match-page/utils.test.ts new file mode 100644 index 000000000..f1df3306f --- /dev/null +++ b/app/components/match-page/utils.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import type { CommonUser } from "~/utils/kysely.server"; +import { inferSubstitutions } from "./utils"; + +function user(id: number): CommonUser { + return { + id, + username: `user${id}`, + discordId: `discord${id}`, + discordAvatar: null, + customUrl: null, + }; +} + +describe("inferSubstitutions", () => { + it("returns an empty array when rosters are unchanged", () => { + const rosters = { + alpha: [user(1), user(2), user(3), user(4)], + bravo: [user(5), user(6), user(7), user(8)], + }; + + expect(inferSubstitutions(rosters, rosters)).toEqual([]); + }); + + it("detects a single substitution on alpha", () => { + const previous = { + alpha: [user(1), user(2), user(3), user(4)], + bravo: [user(5), user(6), user(7), user(8)], + }; + const current = { + alpha: [user(1), user(2), user(3), user(9)], + bravo: previous.bravo, + }; + + expect(inferSubstitutions(previous, current)).toEqual([ + { side: "ALPHA", playerOut: user(4), playerIn: user(9) }, + ]); + }); + + it("detects substitutions on both sides in the same map transition", () => { + const previous = { + alpha: [user(1), user(2)], + bravo: [user(3), user(4)], + }; + const current = { + alpha: [user(1), user(10)], + bravo: [user(11), user(4)], + }; + + expect(inferSubstitutions(previous, current)).toEqual([ + { side: "ALPHA", playerOut: user(2), playerIn: user(10) }, + { side: "BRAVO", playerOut: user(3), playerIn: user(11) }, + ]); + }); + + it("pairs multiple substitutions on the same side by roster order", () => { + const previous = { + alpha: [user(1), user(2), user(3), user(4)], + bravo: [user(5), user(6)], + }; + const current = { + alpha: [user(1), user(10), user(3), user(11)], + bravo: previous.bravo, + }; + + expect(inferSubstitutions(previous, current)).toEqual([ + { side: "ALPHA", playerOut: user(2), playerIn: user(10) }, + { side: "ALPHA", playerOut: user(4), playerIn: user(11) }, + ]); + }); + + it("ignores unpaired leavers when no new player joined", () => { + const previous = { + alpha: [user(1), user(2), user(3), user(4)], + bravo: [user(5), user(6)], + }; + const current = { + alpha: [user(1), user(2), user(3)], + bravo: previous.bravo, + }; + + expect(inferSubstitutions(previous, current)).toEqual([]); + }); + + it("ignores unpaired joiners when no player left", () => { + const previous = { + alpha: [user(1), user(2), user(3)], + bravo: [user(5), user(6)], + }; + const current = { + alpha: [user(1), user(2), user(3), user(9)], + bravo: previous.bravo, + }; + + expect(inferSubstitutions(previous, current)).toEqual([]); + }); + + it("treats players switching sides as separate substitutions on each side", () => { + const previous = { + alpha: [user(1), user(2)], + bravo: [user(3), user(4)], + }; + const current = { + alpha: [user(3), user(4)], + bravo: [user(1), user(2)], + }; + + expect(inferSubstitutions(previous, current)).toEqual([ + { side: "ALPHA", playerOut: user(1), playerIn: user(3) }, + { side: "ALPHA", playerOut: user(2), playerIn: user(4) }, + { side: "BRAVO", playerOut: user(3), playerIn: user(1) }, + { side: "BRAVO", playerOut: user(4), playerIn: user(2) }, + ]); + }); +}); diff --git a/app/components/match-page/utils.ts b/app/components/match-page/utils.ts new file mode 100644 index 000000000..34eeb0bbf --- /dev/null +++ b/app/components/match-page/utils.ts @@ -0,0 +1,47 @@ +import type { CommonUser } from "~/utils/kysely.server"; + +type MatchSide = "ALPHA" | "BRAVO"; + +type Rosters = { + alpha: CommonUser[]; + bravo: CommonUser[]; +}; + +export interface InferredSubstitution { + side: MatchSide; + playerOut: CommonUser; + playerIn: CommonUser; +} + +/** + * Compares the rosters of two consecutive maps and pairs up any + * players that dropped from a side with new players that joined the same side. + * The pairs are returned in roster order, so the first player out is paired with + * the first new player in. When the counts don't match, unpaired players are ignored. + */ +export function inferSubstitutions( + previousRosters: Rosters, + currentRosters: Rosters, +): InferredSubstitution[] { + const result: InferredSubstitution[] = []; + + for (const side of ["alpha", "bravo"] as const) { + const prevIds = new Set(previousRosters[side].map((u) => u.id)); + const currIds = new Set(currentRosters[side].map((u) => u.id)); + + const out = previousRosters[side].filter((u) => !currIds.has(u.id)); + const inn = currentRosters[side].filter((u) => !prevIds.has(u.id)); + + for (let i = 0; i < Math.max(out.length, inn.length); i++) { + if (out[i] && inn[i]) { + result.push({ + side: side === "alpha" ? "ALPHA" : "BRAVO", + playerOut: out[i], + playerIn: inn[i], + }); + } + } + } + + return result; +} diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 54502af07..04b63c443 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -23,7 +23,6 @@ import { } from "~/features/plus-voting/core"; import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; -import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { calculateMatchSkills } from "~/features/sendouq-match/core/skills.server"; import { @@ -3033,9 +3032,7 @@ async function playedMatches() { ["ALPHA", "BRAVO", "ALPHA", "BRAVO", "BRAVO", "BRAVO"], ]) as ("ALPHA" | "BRAVO")[]; const winner = winnersArrayToWinner(winners); - const finishedMatch = SendouQ.mapMatch( - (await SQMatchRepository.findById(match.id))!, - ); + const finishedMatch = (await SQMatchRepository.findById(match.id))!; const { newSkills, differences } = calculateMatchSkills({ groupMatchId: match.id, diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 443646103..ec2dcfdc5 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -27,6 +27,7 @@ import { import { assertUnreachable } from "~/utils/types"; import { sendouQMatchPage } from "~/utils/urls"; import * as RejoinVote from "../core/RejoinVote"; +import * as SendouQMatch from "../core/SendouQMatch"; import { matchSchema, qMatchPageParamsSchema } from "../q-match-schemas"; export const action = async ({ request, params }: ActionFunctionArgs) => { @@ -42,10 +43,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (data._action) { case "REPORT_SCORE": { - const unmappedMatch = notFoundIfFalsy( - await SQMatchRepository.findById(matchId), - ); - const match = SendouQ.mapMatch(unmappedMatch, user); + const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId)); const members = [ ...match.groupAlpha.members, @@ -154,13 +152,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { case "CAST_CONTINUE_VOTE": { const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId)); - // xxx: some SendouQMatch module util - const viewerSide: "ALPHA" | "BRAVO" | null = - match.groupAlpha.members.some((m) => m.id === user.id) - ? "ALPHA" - : match.groupBravo.members.some((m) => m.id === user.id) - ? "BRAVO" - : null; + const viewerSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId: user.id, + }); errorToastIfFalsy(viewerSide, "Not a participant"); const viewerGroup = diff --git a/app/features/sendouq-match/components/RejoinSections.tsx b/app/features/sendouq-match/components/RejoinSections.tsx new file mode 100644 index 000000000..7c664cc0b --- /dev/null +++ b/app/features/sendouq-match/components/RejoinSections.tsx @@ -0,0 +1,108 @@ +import { useTranslation } from "react-i18next"; +import { Link, useFetcher } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { SENDOUQ_PAGE } from "~/utils/urls"; +import * as RejoinVote from "../core/RejoinVote"; +import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; +import { RematchVotePanel } from "./RematchVotePanel"; + +export function MatchmadeRejoinSection({ + data, + viewerGroup, + viewerUserId, + awaitingConfirmation, + isOnReporterTeam, +}: { + data: SendouQMatchLoaderData; + viewerGroup: NonNullable; + viewerUserId: number; + awaitingConfirmation: boolean; + isOnReporterTeam: boolean; +}) { + const voteFetcher = useFetcher(); + + const votes = RejoinVote.extractOwnGroupVotesFromSendouqMatch( + data.match, + viewerUserId, + ); + + if (!votes) return null; + + if (RejoinVote.userContinueStatus(votes, viewerUserId) === false) { + return ; + } + + // During awaiting confirmation, only reporter team can cascade. + if (awaitingConfirmation && !isOnReporterTeam) return null; + + return ( + ({ + id: m.id, + username: m.username, + discordId: m.discordId, + discordAvatar: m.discordAvatar, + customUrl: m.customUrl, + }))} + votes={votes} + viewerUserId={viewerUserId} + fetcher={voteFetcher} + /> + ); +} + +export function TrustedRejoinSection({ + viewerGroup, + viewerUserId, +}: { + viewerGroup: NonNullable; + viewerUserId: number; +}) { + const { t } = useTranslation(["q"]); + const viewerRole = viewerGroup.members.find( + (m) => m.id === viewerUserId, + )?.role; + const lookAgainFetcher = useFetcher(); + + if (viewerRole === "OWNER") { + return ( +
+ { + lookAgainFetcher.submit( + { + _action: "LOOK_AGAIN", + previousGroupId: String(viewerGroup.id), + }, + { method: "post" }, + ); + }} + > + {t("q:match.actions.lookAgain")} + +
+ ); + } + + return ( +

+ {t("q:match.rematch.waitingCaptain")} +

+ ); +} + +function DeclinedSection() { + const { t } = useTranslation(["q"]); + return ( +
+

+ {t("q:match.rematch.declined")} +

+ + {t("q:match.rematch.rejoinQueue")} + +
+ ); +} diff --git a/app/features/sendouq-match/components/RematchVotePanel.tsx b/app/features/sendouq-match/components/RematchVotePanel.tsx index 1208b8ece..88c042774 100644 --- a/app/features/sendouq-match/components/RematchVotePanel.tsx +++ b/app/features/sendouq-match/components/RematchVotePanel.tsx @@ -1,7 +1,9 @@ import { Check, Clock, X } from "lucide-react"; import { useTranslation } from "react-i18next"; +import type { FetcherWithComponents } from "react-router"; import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; import * as RejoinVote from "../core/RejoinVote"; import styles from "./RematchVotePanel.module.css"; @@ -17,21 +19,19 @@ type RematchVotePanelProps = { members: RematchVoteMember[]; votes: RejoinVote.RejoinVote[]; viewerUserId: number; - isPending: boolean; - onVote: (isContinuing: boolean) => void; + fetcher: FetcherWithComponents; }; -// xxx: if Voting no, form with confirm with a warning they cant later change their mind? - export function RematchVotePanel({ members, votes, viewerUserId, - isPending, - onVote, + fetcher, }: RematchVotePanelProps) { const { t } = useTranslation(["q"]); + const isPending = fetcher.state !== "idle"; + const currentRoundSize = RejoinVote.currentUserIds( votes, members.map((m) => m.id), @@ -56,14 +56,23 @@ export function RematchVotePanel({ {RejoinVote.userContinueStatus(votes, viewerUserId) === false ? null : (
- onVote(false)} + - {t("q:match.rematch.vote.no")} - + + {t("q:match.rematch.vote.no")} + + onVote(true)} + onPress={() => + fetcher.submit( + { + _action: "CAST_CONTINUE_VOTE", + isContinuing: "1", + }, + { method: "post" }, + ) + } > {t("q:match.rematch.vote.yes")} diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index 7abc78c96..73643f689 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -1,6 +1,7 @@ +import type { TFunction } from "i18next"; import { Ban, Undo2 } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { Link, useFetcher } from "react-router"; +import { useFetcher } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { SendouTabPanel } from "~/components/elements/Tabs"; import { FormWithConfirm } from "~/components/FormWithConfirm"; @@ -15,20 +16,17 @@ import type { ModeShort, StageId, } from "~/modules/in-game-lists/types"; -import { SENDOUQ_PAGE } from "~/utils/urls"; import { + resolveGroupNames, resolveMatchScore, resolveTimelineMaps, resolveTimelineTeams, } from "../core/match-timeline"; -import * as RejoinVote from "../core/RejoinVote"; import * as SendouQMatch from "../core/SendouQMatch"; import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; -import { RematchVotePanel } from "./RematchVotePanel"; +import { MatchmadeRejoinSection, TrustedRejoinSection } from "./RejoinSections"; import styles from "./SendouQMatchActionTab.module.css"; -// xxx: maybe divide Rejoin related components to a different file? - export function SendouQMatchActionTab({ data, currentMap, @@ -53,14 +51,17 @@ export function SendouQMatchActionTab({ const awaitingConfirmation = !data.match.isLocked && isDecisive; const isLocked = data.match.isLocked; - const cancelRequesterIsAlpha = data.match.groupAlpha.members.some( - (m) => m.id === data.match.cancelRequestedByUserId, - ); - const cancelRequestedByGroupId = data.match.cancelRequestedByUserId - ? cancelRequesterIsAlpha + const cancelRequesterSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: data.match.cancelRequestedByUserId, + }); + const cancelRequestedByGroupId = + cancelRequesterSide === "ALPHA" ? data.match.groupAlpha.id - : data.match.groupBravo.id - : undefined; + : cancelRequesterSide === "BRAVO" + ? data.match.groupBravo.id + : undefined; // xxx: system messages for cancel sent, rejected or accepted and by who if ( @@ -182,10 +183,11 @@ function RequeueTab({ isStaffOnly: boolean; awaitingConfirmation: boolean; }) { + const { t } = useTranslation(["q"]); const user = useUser(); const score = resolveMatchScore(data.match); - const teams = resolveTimelineTeams(data.match); + const teams = resolveTimelineTeams(data.match, t); const maps = resolveTimelineMaps(data.match, data.reportedWeapons); const viewerGroup = @@ -198,13 +200,11 @@ function RequeueTab({ const decidingReportedByUserId = [...data.match.mapList] .reverse() .find((m) => m.winnerGroupId !== null)?.reportedByUserId; - const reporterSide: "ALPHA" | "BRAVO" | null = decidingReportedByUserId - ? data.match.groupAlpha.members.some( - (m) => m.id === decidingReportedByUserId, - ) - ? "ALPHA" - : "BRAVO" - : null; + const reporterSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: decidingReportedByUserId, + }); const isOnReporterTeam = awaitingConfirmation && reporterSide === viewerSide; const isOnConfirmerTeam = awaitingConfirmation && @@ -366,116 +366,6 @@ function ReporterUndoSection() { ); } -function MatchmadeRejoinSection({ - data, - viewerGroup, - viewerUserId, - awaitingConfirmation, - isOnReporterTeam, -}: { - data: SendouQMatchLoaderData; - viewerGroup: NonNullable; - viewerUserId: number; - awaitingConfirmation: boolean; - isOnReporterTeam: boolean; -}) { - const voteFetcher = useFetcher(); - - const votes = RejoinVote.extractOwnGroupVotesFromSendouqMatch( - data.match, - viewerUserId, - ); - - if (!votes) return null; - - if (RejoinVote.userContinueStatus(votes, viewerUserId) === false) { - return ; - } - - // During awaiting confirmation, only reporter team can cascade. - if (awaitingConfirmation && !isOnReporterTeam) return null; - - return ( - ({ - id: m.id, - username: m.username, - discordId: m.discordId, - discordAvatar: m.discordAvatar, - customUrl: m.customUrl, - }))} - votes={votes} - viewerUserId={viewerUserId} - isPending={voteFetcher.state !== "idle"} - onVote={(isContinuing) => { - voteFetcher.submit( - { - _action: "CAST_CONTINUE_VOTE", - isContinuing: String(Number(isContinuing)), - }, - { method: "post" }, - ); - }} - /> - ); -} - -function TrustedRejoinSection({ - viewerGroup, - viewerUserId, -}: { - viewerGroup: NonNullable; - viewerUserId: number; -}) { - const { t } = useTranslation(["q"]); - const viewerRole = viewerGroup.members.find( - (m) => m.id === viewerUserId, - )?.role; - const lookAgainFetcher = useFetcher(); - - if (viewerRole === "OWNER") { - return ( -
- { - lookAgainFetcher.submit( - { - _action: "LOOK_AGAIN", - previousGroupId: String(viewerGroup.id), - }, - { method: "post" }, - ); - }} - > - {t("q:match.actions.lookAgain")} - -
- ); - } - - return ( -

- {t("q:match.rematch.waitingCaptain")} -

- ); -} - -function DeclinedSection() { - const { t } = useTranslation(["q"]); - return ( -
-

- {t("q:match.rematch.declined")} -

- - {t("q:match.rematch.rejoinQueue")} - -
- ); -} - function InProgressTab({ data, currentMap, @@ -521,6 +411,7 @@ function InProgressTab({ ...buildSendouQSetEndingData({ match: data.match, scores, + t, }), setEndingTeamIds, } @@ -538,12 +429,14 @@ function InProgressTab({ .map((w) => w.weaponSplId) : []; + const groupNames = resolveGroupNames(data.match, t); + return ( ; }) { const completedMaps = match.mapList.filter((m) => m.winnerGroupId !== null); @@ -665,20 +560,8 @@ function buildSendouQSetEndingData({ }, })); - const alphaTeam = match.groupAlpha.team; - const bravoTeam = match.groupBravo.team; - return { - teams: { - alpha: { - name: alphaTeam?.name ?? "Group Alpha", - avatar: alphaTeam?.avatarUrl ?? undefined, - }, - bravo: { - name: bravoTeam?.name ?? "Group Bravo", - avatar: bravoTeam?.avatarUrl ?? undefined, - }, - }, + teams: resolveTimelineTeams(match, t), score: { alpha: scores[0], bravo: scores[1] }, maps: previousMaps, currentRosters: { diff --git a/app/features/sendouq-match/components/SendouQMatchBanner.tsx b/app/features/sendouq-match/components/SendouQMatchBanner.tsx index 4e7cfe921..8cdd3cda3 100644 --- a/app/features/sendouq-match/components/SendouQMatchBanner.tsx +++ b/app/features/sendouq-match/components/SendouQMatchBanner.tsx @@ -19,6 +19,7 @@ import { useAutoRerender } from "~/hooks/useAutoRerender"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { databaseTimestampToDate } from "~/utils/dates"; import invariant from "~/utils/invariant"; +import { resolveGroupNames } from "../core/match-timeline"; import * as SendouQMatch from "../core/SendouQMatch"; import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; @@ -26,13 +27,16 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) { const { t } = useTranslation(["q"]); const cancelRequested = Boolean(data.match.cancelRequestedByUserId); - const cancelRequesterIsAlpha = data.match.groupAlpha.members.some( - (m) => m.id === data.match.cancelRequestedByUserId, - ); + const cancelRequesterSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: data.match.cancelRequestedByUserId, + }); + const groupNames = resolveGroupNames(data.match, t); const cancelRequesterName = cancelRequested - ? cancelRequesterIsAlpha - ? (data.match.groupAlpha.team?.name ?? "Group Alpha") - : (data.match.groupBravo.team?.name ?? "Group Bravo") + ? cancelRequesterSide === "ALPHA" + ? groupNames.alpha + : groupNames.bravo : undefined; const bottomRow = ( diff --git a/app/features/sendouq-match/components/SendouQMatchTabs.tsx b/app/features/sendouq-match/components/SendouQMatchTabs.tsx index f0445063a..142e47fa3 100644 --- a/app/features/sendouq-match/components/SendouQMatchTabs.tsx +++ b/app/features/sendouq-match/components/SendouQMatchTabs.tsx @@ -10,7 +10,7 @@ import { useUser } from "~/features/auth/core/user"; import { DISPLAY_VOTE_RESULT_SECONDS } from "~/features/sendouq/q-constants"; import { resolveRoomPass } from "~/features/tournament-match/tournament-match-utils"; import { useHasRole } from "~/modules/permissions/hooks"; -import { databaseTimestampToDate } from "~/utils/dates"; +import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates"; import { safeNumberParse } from "~/utils/number"; import { SENDOUQ_LOOKING_PAGE, sendouQMatchPage, teamPage } from "~/utils/urls"; import { @@ -20,7 +20,6 @@ import { } from "../core/match-timeline"; import * as SendouQMatch from "../core/SendouQMatch"; import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; -import { resolveGroupMemberOf } from "../q-match-utils"; import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog"; import { SendouQMatchActionTab } from "./SendouQMatchActionTab"; @@ -34,7 +33,7 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) { const currentMap = data.match.currentMap; - const userSide = resolveGroupMemberOf({ + const userSide = SendouQMatch.resolveGroupMemberOf({ groupAlpha: data.match.groupAlpha, groupBravo: data.match.groupBravo, userId: user?.id, @@ -62,10 +61,10 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) { return ; } - const now = Math.floor(Date.now() / 1000); const lockedVoteVisible = data.match.confirmedAt !== null && - now < data.match.confirmedAt + DISPLAY_VOTE_RESULT_SECONDS; + databaseTimestampNow() < + data.match.confirmedAt + DISPLAY_VOTE_RESULT_SECONDS; const matchInProgress = !isLocked && !awaitingConfirmation && currentMap; @@ -129,7 +128,7 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) { {isLocked ? ( , userId: number, ): RejoinVote[] | null { - const ownGroup = match.groupAlpha.members.some( - (member) => member.id === userId, - ) - ? match.groupAlpha - : match.groupBravo.members.some((member) => member.id === userId) - ? match.groupBravo - : null; + const ownSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId, + }); + const ownGroup = + ownSide === "ALPHA" + ? match.groupAlpha + : ownSide === "BRAVO" + ? match.groupBravo + : null; if (!ownGroup) return null; diff --git a/app/features/sendouq-match/core/SendouQMatch.ts b/app/features/sendouq-match/core/SendouQMatch.ts index 19d562e61..4c3e43b07 100644 --- a/app/features/sendouq-match/core/SendouQMatch.ts +++ b/app/features/sendouq-match/core/SendouQMatch.ts @@ -20,3 +20,25 @@ export function score(match: { isDecisive: alphaWins >= mapsToWin || bravoWins >= mapsToWin, }; } + +/** + * Returns which side ("ALPHA" or "BRAVO") of the match the given user belongs + * to, or null if they are not a member of either group. + */ +export function resolveGroupMemberOf(args: { + groupAlpha: { members: { id: number }[] }; + groupBravo: { members: { id: number }[] }; + userId: number | null | undefined; +}): "ALPHA" | "BRAVO" | null { + if (!args.userId) return null; + + if (args.groupAlpha.members.some((m) => m.id === args.userId)) { + return "ALPHA"; + } + + if (args.groupBravo.members.some((m) => m.id === args.userId)) { + return "BRAVO"; + } + + return null; +} diff --git a/app/features/sendouq-match/core/match-timeline.ts b/app/features/sendouq-match/core/match-timeline.ts index ff4119a9c..2fc79e3aa 100644 --- a/app/features/sendouq-match/core/match-timeline.ts +++ b/app/features/sendouq-match/core/match-timeline.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import type { TimelineMap, TimelineSpChanges, @@ -8,14 +9,27 @@ type MatchData = SendouQMatchLoaderData["match"]; // xxx: unit test this file -export function resolveTimelineTeams(match: MatchData) { +/** + * Resolves display names for the two groups in a match, falling back to the + * translated "Group Alpha"/"Group Bravo" labels when a group is not associated + * with a registered team. + */ +export function resolveGroupNames(match: MatchData, t: TFunction<["q"]>) { + return { + alpha: match.groupAlpha.team?.name ?? t("q:match.groupAlpha"), + bravo: match.groupBravo.team?.name ?? t("q:match.groupBravo"), + }; +} + +export function resolveTimelineTeams(match: MatchData, t: TFunction<["q"]>) { + const names = resolveGroupNames(match, t); return { alpha: { - name: match.groupAlpha.team?.name ?? "Group Alpha", // xxx: should be in the loader? + name: names.alpha, avatar: match.groupAlpha.team?.avatarUrl ?? undefined, }, bravo: { - name: match.groupBravo.team?.name ?? "Group Bravo", + name: names.bravo, avatar: match.groupBravo.team?.avatarUrl ?? undefined, }, }; diff --git a/app/features/sendouq-match/core/reported-weapons.server.ts b/app/features/sendouq-match/core/reported-weapons.server.ts index 9e5d53e44..63b56e9fa 100644 --- a/app/features/sendouq-match/core/reported-weapons.server.ts +++ b/app/features/sendouq-match/core/reported-weapons.server.ts @@ -1,7 +1,4 @@ -import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import type * as ReportedWeaponRepository from "../ReportedWeaponRepository.server"; -import type * as SQMatchRepository from "../SQMatchRepository.server"; export type ReportedWeaponForMerging = { weaponSplId?: MainWeaponId; @@ -58,43 +55,3 @@ export function mergeReportedWeapons({ typeof w.weaponSplId === "number" ? [w as ReportedWeapon] : [], ); } - -export function reportedWeaponsToArrayOfArrays({ - reportedWeapons, - mapList, - groupAlpha, - groupBravo, -}: { - reportedWeapons: Awaited< - ReturnType - >; - mapList: NonNullable< - Awaited> - >["mapList"]; - groupAlpha: SQMatchGroup; - groupBravo: SQMatchGroup; -}) { - if (!reportedWeapons) return null; - - const result: (MainWeaponId | null)[][] = []; - - const allMembers = [...groupAlpha.members, ...groupBravo.members].map( - (m) => m.id, - ); - - for (const map of mapList) { - const mapWeapons: (MainWeaponId | null)[] = []; - - for (const userId of allMembers) { - const reportedWeapon = reportedWeapons.find( - (wpn) => wpn.groupMatchMapId === map.id && wpn.userId === userId, - ); - - mapWeapons.push(reportedWeapon ? reportedWeapon.weaponSplId : null); - } - - result.push(mapWeapons); - } - - return result; -} diff --git a/app/features/sendouq-match/q-match-utils.ts b/app/features/sendouq-match/q-match-utils.ts index ff91e8c89..12b400cf4 100644 --- a/app/features/sendouq-match/q-match-utils.ts +++ b/app/features/sendouq-match/q-match-utils.ts @@ -7,21 +7,3 @@ export function winnersArrayToWinner(winners: ("ALPHA" | "BRAVO")[]) { return null; } - -export function resolveGroupMemberOf(args: { - groupAlpha: { members: { id: number }[] }; - groupBravo: { members: { id: number }[] }; - userId: number | undefined; -}): "ALPHA" | "BRAVO" | null { - if (!args.userId) return null; - - if (args.groupAlpha.members.some((m) => m.id === args.userId)) { - return "ALPHA"; - } - - if (args.groupBravo.members.some((m) => m.id === args.userId)) { - return "BRAVO"; - } - - return null; -} diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 0d1fcc02b..12738a89d 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -33,8 +33,6 @@ import { import type { SQGroup, SQGroupMember, - SQMatchGroup, - SQMatchGroupMember, SQOwnGroup, } from "../core/SendouQ.server"; import { @@ -62,7 +60,7 @@ export function GroupCard({ showNote = false, ownGroup, }: { - group: SQGroup | SQOwnGroup | SQMatchGroup; + group: SQGroup | SQOwnGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE"; displayOnly?: boolean; hideVc?: SqlBool; @@ -90,16 +88,12 @@ export function GroupCard({ const enableKicking = group.usersRole === "OWNER" && !displayOnly; - // broke after Remix single fetch future flag got toggled on, not sure why this is needed - const members: Array | undefined = - group.members; - return (
- {members ? ( + {group.members ? (
- {members.map((member) => { + {group.members.map((member) => { return ( ; + member: Pick; }) { const user = useUser(); const { t } = useTranslation(["q"]); diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index 69276a7d9..7c28f526f 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -7,6 +7,7 @@ import { defaultOrdinal } from "~/features/mmr/mmr-utils"; import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server"; import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import * as SendouQMatch from "~/features/sendouq-match/core/SendouQMatch"; import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort } from "~/modules/in-game-lists/types"; @@ -43,7 +44,6 @@ export type SQOwnGroup = SerializeFrom< export type SQMatch = SerializeFrom>; export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"]; export type SQGroupMember = NonNullable[number]; -export type SQMatchGroupMember = SQMatchGroup["members"][number]; const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const; const SECONDS_TILL_STALE = @@ -155,7 +155,6 @@ class SendouQClass { return this.groups.find((group) => group.inviteCode === inviteCode); } - // xxx: only needed stuff here /** * Maps a database match to a format with appropriate censoring based on user permissions. * Includes private notes for team members and censors sensitive data for non-participants. @@ -169,14 +168,14 @@ class SendouQClass { /** Array of private user notes to include */ notes: DBPrivateNoteRow[] = [], ) { - const isTeamAlphaMember = match.groupAlpha.members.some( - (m) => m.id === user?.id, - ); - const isTeamBravoMember = match.groupBravo.members.some( - (m) => m.id === user?.id, - ); - const isMatchInsider = - isTeamAlphaMember || isTeamBravoMember || user?.roles.includes("STAFF"); + const viewerSide = SendouQMatch.resolveGroupMemberOf({ + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, + userId: user?.id, + }); + const isTeamAlphaMember = viewerSide === "ALPHA"; + const isTeamBravoMember = viewerSide === "BRAVO"; + const isMatchInsider = viewerSide !== null || user?.roles.includes("STAFF"); const happenedInLastMonth = isWithinInterval( databaseTimestampToDate(match.createdAt), { @@ -191,14 +190,10 @@ class SendouQClass { ) => { return { ...group, - isReplay: false, - tierRange: null as TierRange | null, chatCode: isTeamMember ? group.chatCode : undefined, noScreen: this.#groupNoScreen(group), tier: match.memento?.groups[group.id]?.tier, skillDifference: match.memento?.groups[group.id]?.skillDifference, - modePreferences: this.#groupModePreferences(group), - usersRole: null as Tables["GroupMember"]["role"] | null, matchmade: Boolean(group.matchmade), members: group.members.map((member) => { return { @@ -207,7 +202,6 @@ class SendouQClass { privateNote: null as DBPrivateNoteRow | null, skillDifference: match.memento?.users[member.id]?.skillDifference, noScreen: undefined, - languages: member.languages?.split(",") || [], isContinuing: typeof member.isContinuing === "number" ? Boolean(member.isContinuing) diff --git a/app/features/tournament-bracket/components/CastInfo.tsx b/app/features/tournament-bracket/components/CastInfo.tsx deleted file mode 100644 index 01c1fe618..000000000 --- a/app/features/tournament-bracket/components/CastInfo.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { Lock, LockOpen } from "lucide-react"; -import type { JSX } from "react"; -import { useFetcher } from "react-router"; -import { InfoPopover } from "~/components/InfoPopover"; -import { SubmitButton } from "~/components/SubmitButton"; -import { TournamentMatchStatus } from "~/db/tables"; -import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import styles from "../tournament-bracket.module.css"; - -const lockingInfo = - "You can lock the match to indicate that it should not be started before the cast is ready. Match being locked prevents score reporting and hides the map list till the organizer/streamer unlocks it."; -const setAsCastedInfo = - "Select the Twitch account that is currently casting this match. It is then indicated in the bracket view."; - -export function CastInfo({ - matchIsOngoing, - matchId, - matchIsOver, - matchStatus, -}: { - matchIsOngoing: boolean; - matchId: number; - matchIsOver: boolean; - matchStatus: number; -}) { - const user = useUser(); - const tournament = useTournament(); - - const castedMatchesInfo = tournament.ctx.castedMatchesInfo; - const castTwitchAccounts = tournament.ctx.castTwitchAccounts ?? []; - const currentlyCastedOn = castedMatchesInfo?.castedMatches.find( - (cm) => cm.matchId === matchId, - )?.twitchAccount; - const isLocked = castedMatchesInfo?.lockedMatches?.some( - (lm) => lm.matchId === matchId, - ); - - const hasPerms = tournament.isOrganizerOrStreamer(user); - - if (castTwitchAccounts.length === 0 || !hasPerms || matchIsOver) return null; - - // match can only be locked when status is Locked or Waiting (team(s) busy with previous match) - if ( - (matchStatus === TournamentMatchStatus.Locked || - matchStatus === TournamentMatchStatus.Waiting) && - !isLocked - ) { - return ( - } - infoText={lockingInfo} - > - {castTwitchAccounts.length > 1 ? ( - - ) : ( - - )} - - ); - } - - // if for some reason match is locked in the DB but also has scores reported then the UI - // will act as if it's not locked at all - if (!matchIsOngoing && isLocked) { - return ( - } - infoText={lockingInfo} - /> - ); - } - - return ( - - - - ); -} - -function CastInfoWrapper({ - children, - icon, - submitButtonText, - _action, - infoText, -}: { - children?: React.ReactNode; - icon?: JSX.Element; - submitButtonText?: string; - _action?: string; - infoText?: string; -}) { - const fetcher = useFetcher(); - - return ( -
- -
Cast
- -
- {children ? ( -
{children}
- ) : null} - {submitButtonText && _action ? ( - - {submitButtonText} - - ) : null} -
-
- {infoText ? {infoText} : null} -
- ); -} diff --git a/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx b/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx deleted file mode 100644 index 393832dbb..000000000 --- a/app/features/tournament-bracket/components/DeadlineInfoPopover.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import clsx from "clsx"; -import { differenceInSeconds } from "date-fns"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { InfoPopover } from "~/components/InfoPopover"; -import * as Deadline from "../core/Deadline"; -import styles from "../tournament-bracket.module.css"; - -interface DeadlineInfoPopoverProps { - startedAt: Date; - bestOf: number; - gamesCompleted: number; -} - -export function DeadlineInfoPopover({ - startedAt, - bestOf, - gamesCompleted, -}: DeadlineInfoPopoverProps) { - const { t } = useTranslation(["tournament"]); - const [currentTime, setCurrentTime] = React.useState(new Date()); - - React.useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(new Date()); - }, 5_000); - - return () => clearInterval(interval); - }, []); - - const elapsedMinutes = differenceInSeconds(currentTime, startedAt) / 60; - - const status = Deadline.matchStatus({ - elapsedMinutes, - gamesCompleted, - maxGamesCount: bestOf, - }); - - const warningIndicator = - status === "warning" ? ( - - ! - - ) : status === "error" ? ( - - ! - - ) : null; - - return ( -
- - {t("tournament:match.deadline.explanation")} - - {warningIndicator} -
- ); -} diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx deleted file mode 100644 index 84e1850da..000000000 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ /dev/null @@ -1,527 +0,0 @@ -import clsx from "clsx"; -import * as React from "react"; -import { Link, useFetcher, useLoaderData } from "react-router"; -import { Avatar } from "~/components/Avatar"; -import { SendouButton } from "~/components/elements/Button"; -import { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { useUser } from "~/features/auth/core/user"; -import type { Result } from "~/features/tournament-match/components/StartedMatch"; -import type { loader as tournamentMatchLoader } from "~/features/tournament-match/loaders/to.$id.matches.$mid.server"; -import { inGameNameWithoutDiscriminator } from "~/utils/strings"; -import { tournamentTeamPage, userPage } from "~/utils/urls"; -import { useTournament } from "../../tournament/routes/to.$id"; -import type { TournamentDataTeam } from "../core/Tournament.server"; -import styles from "../tournament-bracket.module.css"; -import { tournamentTeamToActiveRosterUserIds } from "../tournament-bracket-utils"; - -/** Inputs to select who played for teams in a match as well as the winner. Can also be used in a presentational way. */ -export function TeamRosterInputs({ - teams, - winnerId, - setWinnerId, - checkedPlayers, - setCheckedPlayers, - points: _points, - setPoints, - result, - revising, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; - winnerId?: number | null; - checkedPlayers: [number[], number[]]; - setCheckedPlayers?: React.Dispatch< - React.SetStateAction<[number[], number[]]> - >; - points?: [number, number]; - setWinnerId: (newId?: number) => void; - setPoints: React.Dispatch>; - result?: Result; - revising?: boolean; -}) { - const tournament = useTournament(); - - const presentational = !revising && Boolean(result); - - const points = - typeof result?.opponentOnePoints === "number" && - typeof result?.opponentTwoPoints === "number" && - !revising - ? ([result.opponentOnePoints, result.opponentTwoPoints] as [ - number, - number, - ]) - : _points; - - return ( -
- {teams.map((team, teamI) => { - const winnerRadioChecked = result - ? result.winnerTeamId === team.id - : winnerId === team.id; - - return ( - - tournamentTeamToActiveRosterUserIds( - team, - tournament.minMembersPerTeam, - ), - )} - setWinnerId={setWinnerId} - setCheckedPlayers={setCheckedPlayers} - checkedPlayers={checkedPlayers[teamI].join(",")} - winnerRadioChecked={winnerRadioChecked} - points={points ? points[teamI] : undefined} - result={result} - revising={revising} - /> - ); - })} -
- ); -} - -function TeamRoster({ - team, - bothTeamsHaveActiveRosters, - presentational, - idx, - setWinnerId, - setPoints, - setCheckedPlayers, - points, - winnerRadioChecked, - checkedPlayers, - result, - revising, -}: { - team: TournamentDataTeam; - bothTeamsHaveActiveRosters: boolean; - presentational: boolean; - idx: number; - setWinnerId: (newId?: number) => void; - setPoints: React.Dispatch>; - setCheckedPlayers?: React.Dispatch< - React.SetStateAction<[number[], number[]]> - >; - points?: number; - winnerRadioChecked: boolean; - checkedPlayers: string; - result?: Result; - revising?: boolean; -}) { - const tournament = useTournament(); - const activeRoster = tournamentTeamToActiveRosterUserIds( - team, - tournament.minMembersPerTeam, - ); - - const user = useUser(); - - const canEditRoster = - (team.members.some((member) => member.userId === user?.id) || - tournament.isOrganizer(user)) && - !presentational && - team.members.length > tournament.minMembersPerTeam; - const [_editingRoster, _setEditingRoster] = React.useState( - !activeRoster && canEditRoster, - ); - - const editingRoster = revising || _editingRoster; - - const setEditingRoster = (editing: boolean) => { - const didCancel = !editing; - if (didCancel) { - setCheckedPlayers?.((oldPlayers) => { - const newPlayers = structuredClone(oldPlayers); - newPlayers[idx] = activeRoster ?? []; - return newPlayers; - }); - } - - _setEditingRoster(editing); - }; - - const hasPoints = typeof points === "number"; - - // just so we can center the points nicely - const showWinnerRadio = !hasPoints || !presentational || winnerRadioChecked; - - const onPointsChange = React.useCallback( - (newPoint: number) => { - setPoints((points) => { - const newPoints = structuredClone(points); - newPoints[idx] = newPoint; - return newPoints; - }); - }, - [idx, setPoints], - ); - - const checkedInputPlayerIds = () => { - if (result?.participants && !revising) { - return result.participants - .filter( - (participant) => - !participant.tournamentTeamId || - participant.tournamentTeamId === team.id, - ) - .map((participant) => participant.userId); - } - if (editingRoster) return checkedPlayers.split(",").map(Number); - - return activeRoster ?? []; - }; - - const checkedPlayersArray = checkedPlayers.split(",").map(Number); - - return ( -
- -
- {showWinnerRadio ? ( - setWinnerId?.(team.id)} - team={idx + 1} - invisible={!bothTeamsHaveActiveRosters} - /> - ) : null} - {hasPoints ? ( - - ) : null} -
- { - if (!setCheckedPlayers) return; - - setCheckedPlayers((oldPlayers) => { - const newPlayers = structuredClone(oldPlayers); - if (oldPlayers[idx].includes(playerId)) { - newPlayers[idx] = newPlayers[idx].filter((id) => id !== playerId); - } else { - newPlayers[idx].push(playerId); - } - - return newPlayers; - }); - }} - /> - {!revising && canEditRoster ? ( - - ) : null} -
- ); -} - -function TeamRosterHeader({ - idx, - team, - tournamentId, -}: { - idx: number; - team: TournamentDataTeam; - tournamentId: number; -}) { - return ( - <> -
-
- Team {idx + 1} -
-

- {team.seed ? ( - #{team.seed} - ) : null}{" "} - - {team.name} - -

- - ); -} - -/** Renders radio button to select the winner, or in presentational mode just displays the text "Winner" */ -function WinnerRadio({ - presentational, - teamId, - checked, - onChange, - team, - invisible, -}: { - presentational: boolean; - teamId: number; - checked: boolean; - onChange: () => void; - team: number; - invisible: boolean; -}) { - const id = React.useId(); - - if (presentational) { - return ( -
- Winner -
- ); - } - - return ( -
- - -
- ); -} - -function PointInput({ - value, - onChange, - presentational, - disabled, - testId, -}: { - value: number; - onChange: (newPoint: number) => void; - presentational: boolean; - disabled: boolean; - testId?: string; -}) { - const [focused, setFocused] = React.useState(false); - const id = React.useId(); - - if (presentational) { - return ( -
- {value === 100 ? "KO" : <>{value}p} -
- ); - } - - return ( -
- onChange(Number(e.target.value))} - type="number" - min={0} - max={100} - disabled={disabled} - value={focused && !value ? "" : String(value)} - required - id={id} - data-testid={testId} - pattern="[0-9]*" - inputMode="numeric" - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - /> - -
- ); -} - -function TeamRosterInputsCheckboxes({ - teamId, - checkedPlayers, - handlePlayerClick, - presentational, -}: { - teamId: number; - checkedPlayers: number[]; - handlePlayerClick: (playerId: number) => void; - presentational: boolean; -}) { - const data = useLoaderData(); - const id = React.useId(); - const tournament = useTournament(); - - const members = data.match.players.filter( - (p) => p.tournamentTeamId === teamId, - ); - - const mode = () => { - if (presentational) return "PRESENTATIONAL"; - - // Disabled in this case because we expect a result to have exactly - // TOURNAMENT_TEAM_ROSTER_MIN_SIZE members per team when reporting it - // so there is no point to let user to change them around - if (members.length <= tournament.minMembersPerTeam) { - return "DISABLED"; - } - - return "DEFAULT"; - }; - - return ( -
- {members.map((member, i) => { - return ( -
-
- handlePlayerClick(member.id)} - data-testid={`player-checkbox-${i}`} - />{" "} - -
- - - -
- ); - })} -
- ); -} - -function RosterFormWithButtons({ - idx, - editingRoster, - setEditingRoster, - showCancelButton, - checkedPlayers, - teamId, - valid, -}: { - idx: number; - editingRoster: boolean; - setEditingRoster: (editing: boolean) => void; - showCancelButton?: boolean; - checkedPlayers: number[]; - teamId: number; - valid: boolean; -}) { - const fetcher = useFetcher(); - - if (!editingRoster) { - return ( -
- setEditingRoster(true)} - className={styles.editRosterButton} - variant="minimal" - data-testid="edit-active-roster-button" - > - Edit active roster - -
- ); - } - - return ( - - - - - Save - - {showCancelButton ? ( - { - setEditingRoster(false); - }} - > - Cancel - - ) : null} - - ); -} 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 05279bcee..83c073847 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 @@ -716,9 +716,11 @@ export const action: ActionFunction = async ({ params, request }) => { manager.update.match({ id: match.id, opponent1: { + score: match.opponentOne?.score, result: winnerTeamId === match.opponentOne!.id ? "win" : "loss", }, opponent2: { + score: match.opponentTwo?.score, result: winnerTeamId === match.opponentTwo!.id ? "win" : "loss", }, }); diff --git a/app/features/tournament-match/components/MatchActions.tsx b/app/features/tournament-match/components/MatchActions.tsx deleted file mode 100644 index ad2dd47b7..000000000 --- a/app/features/tournament-match/components/MatchActions.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { SquarePen } from "lucide-react"; -import * as React from "react"; -import { Form, useFetcher, useLoaderData } from "react-router"; -import { SendouButton } from "~/components/elements/Button"; -import { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils"; -import { TeamRosterInputs } from "~/features/tournament-bracket/components/TeamRosterInputs"; -import * as PickBan from "~/features/tournament-bracket/core/PickBan"; -import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; -import styles from "~/features/tournament-bracket/tournament-bracket.module.css"; -import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; -import invariant from "~/utils/invariant"; -import type { loader } from "../loaders/to.$id.matches.$mid.server"; -import { isSetOverByScore, matchIsLocked } from "../tournament-match-utils"; -import { MatchActionsBanPicker } from "./MatchActionsBanPicker"; -import type { Result } from "./StartedMatch"; - -export function MatchActions({ - teams, - position, - result, - scores, - presentational: _presentational, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; - position: number; - result?: Result; - scores: [number, number]; - presentational?: boolean; -}) { - const user = useUser(); - const tournament = useTournament(); - const data = useLoaderData(); - - const [checkedPlayers, setCheckedPlayers] = React.useState< - [number[], number[]] - >(() => { - if (result) { - return [ - result.participants - .filter((participant) => - teams[0].members.some( - (member) => - member.userId === participant.userId && - (!participant.tournamentTeamId || - teams[0].id === participant.tournamentTeamId), - ), - ) - .map((p) => p.userId), - result.participants - .filter((participant) => - teams[1].members.some( - (member) => - member.userId === participant.userId && - (!participant.tournamentTeamId || - teams[1].id === participant.tournamentTeamId), - ), - ) - .map((p) => p.userId), - ]; - } - - return [ - tournamentTeamToActiveRosterUserIds( - teams[0], - tournament.minMembersPerTeam, - ) ?? [], - tournamentTeamToActiveRosterUserIds( - teams[1], - tournament.minMembersPerTeam, - ) ?? [], - ]; - }); - - const [winnerId, setWinnerId] = React.useState(); - const [points, setPoints] = React.useState<[number, number]>( - typeof result?.opponentOnePoints === "number" && - typeof result.opponentTwoPoints === "number" - ? [result.opponentOnePoints, result.opponentTwoPoints] - : [0, 0], - ); - const [revising, setRevising] = React.useState(false); - - const presentational = !revising && (_presentational || Boolean(result)); - - const newScore: [number, number] = [ - scores[0] + (winnerId === teams[0].id ? 1 : 0), - scores[1] + (winnerId === teams[1].id ? 1 : 0), - ]; - const wouldEndSet = isSetOverByScore({ - count: data.match.roundMaps.count, - countType: data.match.roundMaps?.type ?? "BEST_OF", - scores: newScore, - }); - - const showPoints = React.useMemo( - () => - tournament.bracketByIdxOrDefault( - tournament.matchIdToBracketIdx(data.match.id) ?? 0, - ).collectResultsWithPoints, - [tournament, data.match.id], - ); - - const bothTeamsHaveActiveRosters = teams.every((team) => - tournamentTeamToActiveRosterUserIds(team, tournament.minMembersPerTeam), - ); - - const turnOf = - data.match.roundMaps && - PickBan.turnOf({ - results: data.results, - maps: data.match.roundMaps, - teams: [ - { id: teams[0].id, seed: tournament.teamById(teams[0].id)!.seed }, - { id: teams[1].id, seed: tournament.teamById(teams[1].id)!.seed }, - ], - mapList: data.mapList, - pickBanEventCount: data.pickBanEventCount, - }); - - if (turnOf && bothTeamsHaveActiveRosters) { - return ( - - ); - } - - const canEditFinishedSet = - result && tournament.isOrganizer(user) && !tournament.ctx.isFinalized; - - return ( -
- - {!presentational && bothTeamsHaveActiveRosters ? ( -
- - {showPoints ? ( - - ) : null} - - {!revising && ( - - )} - - ) : null} - {canEditFinishedSet ? ( - - teamMembers.length !== tournament.minMembersPerTeam, - )} - /> - ) : null} - {!result && presentational ? ( -
-

- No permissions to report score -

-
- ) : null} -
- ); - - function winnerOfSetName() { - if (!winnerId) return; - - const setWinningIdx = newScore[0] > newScore[1] ? 0 : 1; - - const result = teams[setWinningIdx].name; - invariant(result, "No set winning team"); - - return result; - } - - function winningTeamIdx() { - if (!winnerId) return; - if (teams[0].id === winnerId) return 0; - if (teams[1].id === winnerId) return 1; - - throw new Error("No winning team matching the id"); - } -} - -function ReportScoreButtons({ - points, - winnerIdx, - winnerOfSetName, - wouldEndSet, - matchLocked, - newScore, -}: { - points?: [number, number]; - winnerIdx?: number; - winnerOfSetName?: string; - wouldEndSet: boolean; - matchLocked: boolean; - newScore: [number, number]; -}) { - const data = useLoaderData(); - const user = useUser(); - const tournament = useTournament(); - const confirmCheckId = React.useId(); - const pointConfirmCheckId = React.useId(); - const [endConfirmation, setEndConfirmation] = React.useState(false); - const [pointConfirmation, setPointConfirmation] = React.useState(false); - - if (isLeagueRoundLocked(tournament, data.match.roundId)) { - return ( -

- League round has not started yet -

- ); - } - - if (matchLocked) { - return ( -

- Match is pending to be casted. Please wait a bit -

- ); - } - - if ( - points && - typeof winnerIdx === "number" && - points[winnerIdx] <= points[winnerIdx === 0 ? 1 : 0] - ) { - return ( -

- Winner should have higher score than loser -

- ); - } - - if ( - points && - ((points[0] === 100 && points[1] !== 0) || - (points[0] !== 0 && points[1] === 100)) - ) { - return ( -

- If there was a KO (100 score), other team should have 0 score -

- ); - } - - if (typeof winnerIdx !== "number") { - return ( -

- Please select the winner of this map -

- ); - } - - const confirmationClass = () => { - const ownTeam = tournament.teamMemberOfByUser(user); - - // TO reporting - if (!ownTeam) return "text-main-forced"; - if (ownTeam.name === winnerOfSetName) return "text-success"; - - return "text-warning"; - }; - - const lowPoints = points?.every((point) => point < 10); - const submitButtonDisabled = () => { - if (wouldEndSet && !endConfirmation) return true; - if (lowPoints && !pointConfirmation) return true; - - return false; - }; - - return ( -
- {wouldEndSet ? ( -
- setEndConfirmation(e.target.checked)} - id={confirmCheckId} - data-testid="end-confirmation" - /> - -
- ) : null} - {lowPoints ? ( -
- setPointConfirmation(e.target.checked)} - id={pointConfirmCheckId} - /> - -
- ) : null} - - {wouldEndSet ? "Report & end set" : "Report"} - -
- ); -} - -function EditScoreForm({ - editing, - setEditing, - checkedPlayers, - resultId, - points, - submitDisabled, -}: { - editing: boolean; - setEditing: (value: boolean) => void; - checkedPlayers: [number[], number[]]; - resultId: number; - points?: [number, number]; - submitDisabled: boolean; -}) { - const fetcher = useFetcher(); - - if (editing) { - return ( - - - - {points ? ( - - ) : undefined} - - Save - - setEditing(false)} - > - Cancel - - - ); - } - - return ( -
- } - variant="outlined" - size="small" - className="mx-auto" - onPress={() => setEditing(true)} - data-testid="revise-button" - > - Edit - -
- ); -} diff --git a/app/features/tournament-match/components/MatchActionsBanPicker.module.css b/app/features/tournament-match/components/MatchActionsBanPicker.module.css deleted file mode 100644 index 1abe61f07..000000000 --- a/app/features/tournament-match/components/MatchActionsBanPicker.module.css +++ /dev/null @@ -1,89 +0,0 @@ -.mapPoolPicker { - --map-width: 90px; - --map-height: 50px; -} - -.divider { - font-size: var(--font-xs); - font-weight: var(--weight-semi); - text-transform: uppercase; - display: flex; - gap: var(--s-2); - - &::before, - &::after { - border-bottom: 2px dotted var(--color-bg-higher); - } -} - -.mapButton { - background-image: var(--map-image-url); - background-size: cover; - height: var(--map-height); - width: var(--map-width); - border: none; - background-color: transparent; - transition: - filter, - opacity 0.2s; - border-radius: var(--radius-box); - - &:active { - transform: none; - } -} - -.mapButtonGreyedOut { - filter: grayscale(100%) !important; - opacity: 0.4 !important; -} - -.mapButtonIcon { - position: absolute; - top: 2px; - color: var(--color-success); - width: 48px; - height: 48px; - cursor: pointer; -} - -.mapButtonIconError { - color: var(--color-error); -} - -.mapButtonIconMuted { - color: var(--color-text-high); -} - -.mapButtonNumber { - position: absolute; - background-color: var(--color-text-accent); - border-radius: 100%; - width: 18px; - height: 18px; - display: grid; - place-items: center; - color: var(--color-text-inverse); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - top: -5px; - left: 0; -} - -.mapButtonFrom { - position: absolute; - bottom: -15px; - font-size: var(--font-xs); - font-weight: var(--weight-bold); -} - -.mapButtonContainer { - width: var(--map-width); - text-align: center; -} - -.mapButtonLabel { - font-size: var(--font-2xs); - color: var(--color-text-high); - font-weight: var(--weight-semi); -} diff --git a/app/features/tournament-match/components/MatchActionsBanPicker.tsx b/app/features/tournament-match/components/MatchActionsBanPicker.tsx deleted file mode 100644 index d159be619..000000000 --- a/app/features/tournament-match/components/MatchActionsBanPicker.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import clsx from "clsx"; -import { Check, X } from "lucide-react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useFetcher, useLoaderData } from "react-router"; -import { Divider } from "~/components/Divider"; -import { ModeImage, StageImage } from "~/components/Image"; -import { SubmitButton } from "~/components/SubmitButton"; -import type { ActionType, TournamentRoundMaps } from "~/db/tables"; -import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import * as PickBan from "~/features/tournament-bracket/core/PickBan"; -import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; -import { modesShort } from "~/modules/in-game-lists/modes"; -import { shortStageName } from "~/modules/in-game-lists/stage-ids"; -import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import invariant from "~/utils/invariant"; -import { stageImageUrl } from "~/utils/urls"; -import type { loader } from "../loaders/to.$id.matches.$mid.server"; -import styles from "./MatchActionsBanPicker.module.css"; - -/** stageId is omitted for mode-only actions (MODE_PICK / MODE_BAN) where no specific stage is selected */ -type BanPickerSelection = { - mode: ModeShort; - stageId?: StageId; -}; - -export function MatchActionsBanPicker({ - teams, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; -}) { - const data = useLoaderData(); - const tournament = useTournament(); - const maps = data.match.roundMaps!; - const [selected, setSelected] = React.useState(); - - const turnOfResult = PickBan.turnOf({ - results: data.results, - maps, - teams: [ - { id: teams[0].id, seed: tournament.teamById(teams[0].id)!.seed }, - { id: teams[1].id, seed: tournament.teamById(teams[1].id)!.seed }, - ], - mapList: data.mapList, - pickBanEventCount: data.pickBanEventCount, - })!; - const pickerTeamId = turnOfResult.teamId; - const pickingTeam = teams.find((team) => team.id === pickerTeamId)!; - - const actionType = turnOfResult.action; - const isModePick = actionType === "MODE_PICK"; - const isModeBan = actionType === "MODE_BAN"; - const isModeAction = isModePick || isModeBan; - - return ( -
- {isModeAction ? ( - - ) : ( - - )} - -
- ); -} - -function MapPicker({ - selected, - setSelected, - pickerTeamId, - teams, - actionType, -}: { - selected?: BanPickerSelection; - setSelected: (selected: BanPickerSelection) => void; - pickerTeamId: number; - teams: [TournamentDataTeam, TournamentDataTeam]; - actionType: ActionType; -}) { - const user = useUser(); - const data = useLoaderData(); - const tournament = useTournament(); - - const pickBanMapPool = PickBan.mapsListWithLegality({ - toSetMapPool: tournament.ctx.toSetMapPool, - maps: data.match.roundMaps, - mapList: data.mapList, - teams, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, - pickerTeamId, - results: data.results, - pickBanEvents: data.pickBanEvents, - }); - - const modes = modesShort.filter((mode) => - pickBanMapPool.some((map) => map.mode === mode && map.isLegal), - ); - - const canPickBan = - tournament.isOrganizer(user) || - tournament.ownedTeamByUser(user)?.id === pickerTeamId; - - const teamMemberOf = tournament.teamMemberOfByUser(user); - const isPartOfTheMatch = teams.some((t) => t.id === teamMemberOf?.id); - const mapFromWhere = (stageId: StageId, mode: ModeShort) => { - if (!isPartOfTheMatch) { - return; - } - - const teamOneHas = teams[0].mapPool?.some( - (map) => map.stageId === stageId && map.mode === mode, - ); - const teamTwoHas = teams[1].mapPool?.some( - (map) => map.stageId === stageId && map.mode === mode, - ); - - if (teamOneHas && teamTwoHas) { - return "BOTH"; - } - - if (teamOneHas) { - return teams[0].id === teamMemberOf?.id ? "US" : "THEM"; - } - - if (teamTwoHas) { - return teams[1].id === teamMemberOf?.id ? "US" : "THEM"; - } - - return; - }; - - const pickersLastWonMode = data.results - .slice() - .reverse() - .find((result) => result.winnerTeamId === pickerTeamId)?.mode; - - return ( -
- {modes.map((mode) => { - const stages = pickBanMapPool - .filter((map) => map.mode === mode) - .sort((a, b) => a.stageId - b.stageId); - - return ( -
- - - -
- {stages.map(({ stageId, isLegal }) => { - const number = - data.match.roundMaps?.pickBan === "BAN_2" - ? (data.mapList ?? [])?.findIndex( - (m) => m.stageId === stageId && m.mode === mode, - ) + 1 - : undefined; - - return ( - setSelected({ mode, stageId }) - : undefined - } - number={number} - from={mapFromWhere(stageId, mode)} - /> - ); - })} -
- {data.match.roundMaps?.pickBan !== "CUSTOM" && - pickersLastWonMode === mode && - modes.length > 1 ? ( -
- Can't pick the same mode team last won on -
- ) : null} -
- ); - })} -
- ); -} - -function MapButton({ - stageId, - onClick, - selected, - disabled, - actionType, - number, - from, -}: { - stageId: StageId; - onClick?: () => void; - selected?: boolean; - disabled?: boolean; - actionType?: ActionType; - number?: number; - from?: "US" | "THEM" | "BOTH"; -}) { - const { t } = useTranslation(["game-misc"]); - - return ( -
-
- ); -} - -function ModePicker({ - selected, - setSelected, - pickerTeamId, - teams, -}: { - selected?: BanPickerSelection; - setSelected: (selected: BanPickerSelection) => void; - pickerTeamId: number; - teams: [TournamentDataTeam, TournamentDataTeam]; -}) { - const user = useUser(); - const data = useLoaderData(); - const tournament = useTournament(); - const { t } = useTranslation(["game-misc"]); - - const pickBanMapPool = PickBan.mapsListWithLegality({ - toSetMapPool: tournament.ctx.toSetMapPool, - maps: data.match.roundMaps, - mapList: data.mapList, - teams, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, - pickerTeamId, - results: data.results, - pickBanEvents: data.pickBanEvents, - }); - - const availableModes = modesShort.filter((mode) => - pickBanMapPool.some((map) => map.mode === mode && map.isLegal), - ); - - const canPickBan = - tournament.isOrganizer(user) || - tournament.ownedTeamByUser(user)?.id === pickerTeamId; - - return ( -
- {availableModes.map((mode) => ( - - ))} -
- ); -} - -function CounterpickSubmitter({ - selected, - pickingTeam, - pickBan, - actionType, -}: { - selected?: BanPickerSelection; - pickingTeam: TournamentDataTeam; - pickBan: NonNullable; - actionType: ActionType; -}) { - const fetcher = useFetcher(); - const { t } = useTranslation(["game-misc"]); - const user = useUser(); - const tournament = useTournament(); - - const ownedTeam = tournament.ownedTeamByUser(user); - - const picking = - tournament.isOrganizer(user) || ownedTeam?.id === pickingTeam.id; - - const isModeAction = actionType === "MODE_PICK" || actionType === "MODE_BAN"; - - const isCustom = pickBan === "CUSTOM"; - - const actionLabel = () => { - if (actionType === "BAN" || pickBan === "BAN_2") return "Ban"; - if (actionType === "MODE_PICK") return "Pick mode"; - if (actionType === "MODE_BAN") return "Ban mode"; - if (isCustom) return "Pick"; - return "Counterpick"; - }; - - const promptLabel = () => { - if (actionType === "BAN" || pickBan === "BAN_2") { - return "Please select your team's ban above"; - } - if (actionType === "MODE_PICK") return "Please select a mode to pick above"; - if (actionType === "MODE_BAN") return "Please select a mode to ban above"; - if (isCustom) return "Please select your team's pick above"; - return "Please select your team's counterpick above"; - }; - - if (!picking) { - return ( -
- Waiting for captain of {pickingTeam.name} to make their selection -
- ); - } - - if (picking && !selected) { - return ( -
- {promptLabel()} -
- ); - } - - invariant(selected, "CounterpickSubmitter: selected is undefined"); - - const stageId = isModeAction ? null : selected.stageId; - invariant(isModeAction || typeof stageId === "number", "Expected stageId"); - - return ( -
-
- {actionLabel()}: {t(`game-misc:MODE_SHORT_${selected.mode}`)} - {typeof stageId === "number" - ? ` ${t(`game-misc:STAGE_${stageId}`)}` - : null} -
-
- - {typeof stageId === "number" ? ( - - ) : null} -
- - {typeof stageId === "number" ? ( - - ) : null} - - Confirm - -
- ); -} diff --git a/app/features/tournament-match/components/MatchRosters.tsx b/app/features/tournament-match/components/MatchRosters.tsx deleted file mode 100644 index 2f54301de..000000000 --- a/app/features/tournament-match/components/MatchRosters.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import clsx from "clsx"; -import { Link, useLoaderData } from "react-router"; -import { Avatar } from "~/components/Avatar"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import styles from "~/features/tournament-bracket/tournament-bracket.module.css"; -import { tournamentTeamPage, userPage } from "~/utils/urls"; -import type { loader } from "../loaders/to.$id.matches.$mid.server"; - -export function MatchRosters({ - teams, -}: { - teams: [id: number | null | undefined, id: number | null | undefined]; -}) { - const data = useLoaderData(); - const tournament = useTournament(); - - const teamOne = teams[0] ? tournament.teamById(teams[0]) : undefined; - const teamTwo = teams[1] ? tournament.teamById(teams[1]) : undefined; - const teamOnePlayers = data.match.players.filter( - (p) => p.tournamentTeamId === teamOne?.id, - ); - const teamTwoPlayers = data.match.players.filter( - (p) => p.tournamentTeamId === teamTwo?.id, - ); - - const teamOneParticipatedPlayers = teamOnePlayers.filter((p) => - tournament.ctx.participatedUsers.includes(p.id), - ); - const teamTwoParticipatedPlayers = teamTwoPlayers.filter((p) => - tournament.ctx.participatedUsers.includes(p.id), - ); - - const teamOneLogoSrc = teamOne - ? tournament.tournamentTeamLogoSrc(teamOne) - : null; - const teamTwoLogoSrc = teamTwo - ? tournament.tournamentTeamLogoSrc(teamTwo) - : null; - - return ( -
-
-
-
- Team 1 -
-

- {teamOne ? ( - - - {teamOne.name} - - ) : ( - "Waiting on team" - )} -

- {teamOnePlayers.length > 0 ? ( -
    - {teamOnePlayers.map((p) => { - const isInactive = - teamOneParticipatedPlayers.length > 0 && - teamOneParticipatedPlayers.every( - (participatedPlayer) => p.id !== participatedPlayer.id, - ); - - return ( -
  • - - - {p.username} - {p.pronouns ? ( - - {p.pronouns.subject}/{p.pronouns.object} - - ) : null} - -
  • - ); - })} -
- ) : null} -
-
-
-
- Team 2 -
-

- {teamTwo ? ( - - - {teamTwo.name} - - ) : ( - "Waiting on team" - )} -

- {teamTwoPlayers.length > 0 ? ( -
    - {teamTwoPlayers.map((p) => { - const isInactive = - teamTwoParticipatedPlayers.length > 0 && - teamTwoParticipatedPlayers.every( - (participatedPlayer) => p.id !== participatedPlayer.id, - ); - - return ( -
  • - - - {p.username} - {p.pronouns ? ( - - {p.pronouns.subject}/{p.pronouns.object} - - ) : null} - -
  • - ); - })} -
- ) : null} -
-
- ); -} diff --git a/app/features/tournament-match/components/MatchTimer.module.css b/app/features/tournament-match/components/MatchTimer.module.css deleted file mode 100644 index d7adc8d4e..000000000 --- a/app/features/tournament-match/components/MatchTimer.module.css +++ /dev/null @@ -1,65 +0,0 @@ -.progressContainer { - position: relative; - width: 100%; - height: 18px; - background-color: var(--color-bg); -} - -.progressBar { - position: absolute; - left: 0; - top: 0; - height: 100%; - background-color: var(--color-text-accent); - transition: - width 0.5s ease-in-out, - background-color 0.3s ease; - z-index: 1; -} - -.gameMarker { - position: absolute; - top: 0; - height: 100%; - display: flex; - flex-direction: row; - align-items: center; - gap: var(--s-1); - z-index: 2; - pointer-events: none; - transform: translateX(-50%); -} - -.gameMarkerLine { - width: 2px; - height: 100%; - background-color: var(--color-text); - opacity: 0.6; -} - -.maxTimeMarker { - position: absolute; - right: 0; - top: 0; - height: 100%; - display: flex; - flex-direction: row; - align-items: center; - gap: var(--s-1); - z-index: 2; - pointer-events: none; - padding-right: var(--s-1); -} - -.gameMarkerText { - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - white-space: nowrap; - text-shadow: 0 0 3px var(--color-text-inverse); -} - -.gameMarkerHidden { - & .gameMarkerText { - visibility: hidden; - } -} diff --git a/app/features/tournament-match/components/MatchTimer.tsx b/app/features/tournament-match/components/MatchTimer.tsx deleted file mode 100644 index aadaacf57..000000000 --- a/app/features/tournament-match/components/MatchTimer.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { clsx } from "clsx"; -import { differenceInSeconds } from "date-fns"; -import * as React from "react"; -import * as Deadline from "~/features/tournament-bracket/core/Deadline"; -import styles from "./MatchTimer.module.css"; - -interface MatchTimerProps { - startedAt: Date; - bestOf: number; -} - -export function MatchTimer({ startedAt, bestOf }: MatchTimerProps) { - const [currentTime, setCurrentTime] = React.useState(new Date()); - - React.useEffect(() => { - const interval = setInterval(() => { - setCurrentTime(new Date()); - }, 5_000); - - return () => clearInterval(interval); - }, []); - - const elapsedMinutes = differenceInSeconds(currentTime, startedAt) / 60; - - const totalMinutes = Deadline.totalMatchTime(bestOf); - const progressPercentage = Deadline.progressPercentage( - elapsedMinutes, - totalMinutes, - ); - const gameMarkers = Deadline.gameMarkers(bestOf); - - return ( -
-
-
- - {gameMarkers.map((marker) => ( -
-
- G{marker.gameNumber} -
-
-
- Start -
-
- ))} - -
-
- Max -
-
- {totalMinutes}min -
-
-
-
- ); -} diff --git a/app/features/tournament-match/components/StartedMatch.tsx b/app/features/tournament-match/components/StartedMatch.tsx deleted file mode 100644 index 1127995e9..000000000 --- a/app/features/tournament-match/components/StartedMatch.tsx +++ /dev/null @@ -1,872 +0,0 @@ -import clsx from "clsx"; -import { differenceInMinutes } from "date-fns"; -import type { TFunction } from "i18next"; -import { Check, MousePointerClick, X } from "lucide-react"; -import type { JSX } from "react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Form, useLoaderData } from "react-router"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouPopover } from "~/components/elements/Popover"; -import { - SendouTab, - SendouTabList, - SendouTabPanel, - SendouTabs, -} from "~/components/elements/Tabs"; -import { Image } from "~/components/Image"; -import { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { useUser } from "~/features/auth/core/user"; -import { useTournament } from "~/features/tournament/routes/to.$id"; -import { - isLeagueRoundLocked, - resolveLeagueRoundStartDate, -} from "~/features/tournament/tournament-utils"; -import { DeadlineInfoPopover } from "~/features/tournament-bracket/components/DeadlineInfoPopover"; -import type { Bracket } from "~/features/tournament-bracket/core/Bracket"; -import * as Deadline from "~/features/tournament-bracket/core/Deadline"; -import * as PickBan from "~/features/tournament-bracket/core/PickBan"; -import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; -import styles from "~/features/tournament-bracket/tournament-bracket.module.css"; -import { - groupNumberToLetters, - tournamentTeamToActiveRosterUserIds, -} from "~/features/tournament-bracket/tournament-bracket-utils"; -import { useHydrated } from "~/hooks/useHydrated"; -import { useSearchParamState } from "~/hooks/useSearchParamState"; -import type { StageId } from "~/modules/in-game-lists/types"; -import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; -import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types"; -import { nullFilledArray } from "~/utils/arrays"; -import { databaseTimestampToDate } from "~/utils/dates"; -import type { Unpacked } from "~/utils/types"; -import { - modeImageUrl, - specialWeaponImageUrl, - stageImageUrl, -} from "~/utils/urls"; -import type { - loader, - TournamentMatchLoaderData, -} from "../loaders/to.$id.matches.$mid.server"; -import { - mapCountPlayedInSetWithCertainty, - matchIsLocked, - pickInfoText, - resolveHostingTeam, - resolveRoomPass, -} from "../tournament-match-utils"; -import { MatchActions } from "./MatchActions"; -import { MatchRosters } from "./MatchRosters"; -import { MatchTimer } from "./MatchTimer"; - -export type Result = Unpacked; - -export function StartedMatch({ - teams, - currentStageWithMode, - selectedResultIndex, - setSelectedResultIndex, - result, - type, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; - result?: Result; - currentStageWithMode?: TournamentMapListMap; - selectedResultIndex?: number; - // if this is set it means the component is being used in presentation manner - setSelectedResultIndex?: (index: number) => void; - type: "EDIT" | "OTHER"; -}) { - const { t } = useTranslation(["tournament"]); - const isHydrated = useHydrated(); - const user = useUser(); - const tournament = useTournament(); - const data = useLoaderData(); - - const scoreOne = data.match.opponentOne?.score ?? 0; - const scoreTwo = data.match.opponentTwo?.score ?? 0; - - const currentPosition = scoreOne + scoreTwo; - - const presentational = Boolean(setSelectedResultIndex); - - const showFullInfos = !presentational && type === "EDIT"; - - const isMemberOfTeamParticipating = data.match.players.some( - (p) => p.id === user?.id, - ); - - const waitingForPreviousMatch = data.match.status === 0; - - const hostingTeamId = resolveHostingTeam(teams).id; - const poolCode = React.useMemo(() => { - const match = tournament.brackets - .flatMap((b) => b.data.match) - .find((m) => m.id === data.match.id); - - 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] as Bracket | undefined; - const group = tournament.brackets - .flatMap((b) => b.data.group) - .find((group) => group.id === match?.group_id); - return tournament.resolvePoolCode({ - hostingTeamId, - groupLetters: - group && bracket?.type === "round_robin" - ? groupNumberToLetters(group.number) - : undefined, - bracketNumber: - hasRoundRobin && bracket?.type !== "round_robin" - ? bracketIdx + 1 - : undefined, - }); - }, [tournament, hostingTeamId, data.match.id]); - - const roundInfos = [ - showFullInfos ? ( - - {t("tournament:match.hosts", { - teamName: resolveHostingTeam(teams).name, - })} - - ) : null, - showFullInfos ? ( - - {t("tournament:match.pass")}{" "} - - {resolveRoomPass(hostingTeamId)} - - - ) : null, - showFullInfos ? ( - - {t("tournament:match.pool")} {poolCode.prefix} - {poolCode.suffix} - - ) : null, - - {data.match.roundMaps?.type === "PLAY_ALL" - ? t("tournament:match.score.playAll", { - scoreOne, - scoreTwo, - bestOf: data.match.bestOf, - }) - : t("tournament:match.score", { - scoreOne, - scoreTwo, - bestOf: data.match.bestOf, - })} - , - tournament.ctx.settings.enableNoScreenToggle && - typeof data.noScreen === "boolean" ? ( - - ) : null, - ]; - - return ( -
- - {currentPosition > 0 && - !presentational && - type === "EDIT" && - (tournament.isOrganizer(user) || isMemberOfTeamParticipating) && ( -
- -
- - {t("tournament:match.action.undoLastScore")} - -
-
- )} - {tournament.isOrganizer(user) && - tournament.matchCanBeReopened(data.match.id) && - presentational && ( -
-
- - {t("tournament:match.action.reopenMatch")} - -
-
- )} - {tournament.isOrganizer(user) && - !data.matchIsOver && - data.match.startedAt && - Deadline.matchStatus({ - elapsedMinutes: differenceInMinutes( - new Date(), - databaseTimestampToDate(data.match.startedAt), - ), - gamesCompleted: scoreOne + scoreTwo, - maxGamesCount: data.match.bestOf, - }) === "error" ? ( - - ) : null} -
- - {!waitingForPreviousMatch && (type === "EDIT" || presentational) ? ( - - ) : null} - {result ? ( -
- {isHydrated - ? databaseTimestampToDate(result.createdAt).toLocaleString() - : "t"} -
- ) : null} -
- ); -} - -function FancyStageBanner({ - stage, - infos, - children, - teams, - matchIsLocked, - waitingForPreviousMatch, -}: { - stage?: TournamentMapListMap; - infos?: (JSX.Element | null)[]; - children?: React.ReactNode; - teams: [TournamentDataTeam, TournamentDataTeam]; - matchIsLocked: boolean; - waitingForPreviousMatch: boolean; -}) { - const user = useUser(); - const data = useLoaderData(); - const { t } = useTranslation(["game-misc", "tournament"]); - const tournament = useTournament(); - - const gamesCompleted = data.results.length; - - const stageNameToBannerImageUrl = (stageId: StageId) => { - return `${stageImageUrl(stageId)}.avif`; - }; - - const turnOfResult = (() => { - if ( - !data.match.roundMaps || - !data.match.opponentOne?.id || - !data.match.opponentTwo?.id - ) { - return null; - } - - return PickBan.turnOf({ - results: data.results, - maps: data.match.roundMaps, - teams: [ - { - id: data.match.opponentOne.id, - seed: tournament.teamById(data.match.opponentOne.id)!.seed, - }, - { - id: data.match.opponentTwo.id, - seed: tournament.teamById(data.match.opponentTwo.id)!.seed, - }, - ], - mapList: data.mapList, - pickBanEventCount: data.pickBanEventCount, - }); - })(); - - const banPickingTeam = () => { - return turnOfResult - ? teams.find((t) => t.id === turnOfResult.teamId) - : null; - }; - - const style = { - "--_tournament-bg-url": stage - ? `url("${stageNameToBannerImageUrl(stage.stageId)}")` - : undefined, - }; - - const inBanPhase = - !data.matchIsOver && - data.match.roundMaps?.pickBan === "BAN_2" && - data.mapList && - data.mapList.filter((m) => m.bannedByTournamentTeamId).length < 2; - - const waitingForActiveRosterSelectionFor = (() => { - if (data.results.length > 0) return null; - - const teamOneMissing = !tournamentTeamToActiveRosterUserIds( - teams[0], - tournament.minMembersPerTeam, - ); - const teamTwoMissing = !tournamentTeamToActiveRosterUserIds( - teams[1], - tournament.minMembersPerTeam, - ); - - if (teamOneMissing && teamTwoMissing) { - return "BOTH"; - } - - if (teamOneMissing) { - return teams[0].name; - } - - if (teamTwoMissing) { - return teams[1].name; - } - - return null; - })(); - - const waitingForLeagueRoundToStart = isLeagueRoundLocked( - tournament, - data.match.roundId, - ); - - const noStageHeading = () => { - if (data.match.roundMaps?.pickBan === "CUSTOM" && turnOfResult) { - const stepCounter = - turnOfResult.stepTotal && turnOfResult.stepTotal > 1 - ? ` (${turnOfResult.stepCurrent}/${turnOfResult.stepTotal})` - : ""; - - switch (turnOfResult.action) { - case "PICK": - return t("tournament:pickBan.pickMap") + stepCounter; - case "BAN": - return t("tournament:pickBan.banMap") + stepCounter; - case "MODE_PICK": - return t("tournament:pickBan.pickMode") + stepCounter; - case "MODE_BAN": - return t("tournament:pickBan.banMode") + stepCounter; - default: - return t("tournament:pickBan.counterpick"); - } - } - return t("tournament:pickBan.counterpick"); - }; - - return ( - <> - {matchIsLocked ? ( -
-
-
- Match locked to be casted -
-
Please wait for staff to unlock
-
-
- ) : waitingForLeagueRoundToStart ? ( -
-
-
- Waiting for league round to start -
-
- Round playable from{" "} - {resolveLeagueRoundStartDate( - tournament, - data.match.roundId, - )!.toLocaleDateString()}{" "} - onwards -
-
-
- ) : waitingForPreviousMatch ? ( -
-
-
- Previous match ongoing -
-
- Match will be reportable when both teams are ready to play -
-
-
- ) : waitingForActiveRosterSelectionFor ? ( -
-
-
- Active rosters need to be selected -
-
- Waiting on{" "} - {waitingForActiveRosterSelectionFor === "BOTH" - ? "both teams" - : waitingForActiveRosterSelectionFor} -
-
- {data.match.startedAt && - !tournament.isLeagueDivision && - (waitingForActiveRosterSelectionFor || !stage || inBanPhase) ? ( - - ) : null} -
- ) : inBanPhase ? ( -
-
-
Banning phase
-
Waiting for {banPickingTeam()?.name}
-
-
- ) : !stage ? ( -
-
-
- {noStageHeading()} -
-
Waiting for {banPickingTeam()?.name}
- {children} -
-
- ) : ( -
-
-

- - - {t(`game-misc:MODE_SHORT_${stage.mode}`)}{" "} - {t(`game-misc:STAGE_${stage.stageId}`)} - - - {t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "} - {t(`game-misc:STAGE_${stage.stageId}`)} - -

-

- {pickInfoText({ - t: t as unknown as TFunction<["tournament"]>, - teams, - map: stage, - })} -

-
- {data.match.startedAt && - !tournament.isLeagueDivision && - !data.matchIsOver ? ( - - ) : null} - {children} -
- )} - {(tournament.isOrganizer(user) || - teams.some((t) => t.members.some((m) => m.userId === user?.id))) && - !tournament.isLeagueDivision && - !matchIsLocked && - data.match.startedAt && - !data.matchIsOver ? ( - - ) : null} - {infos && ( -
- {infos.filter(Boolean).map((info, i) => ( -
{info}
- ))} -
- )} - - ); -} - -function ModeProgressIndicator({ - scores, - bestOf, - selectedResultIndex, - setSelectedResultIndex, -}: { - scores: [number, number]; - bestOf: number; - selectedResultIndex?: number; - setSelectedResultIndex?: (index: number) => void; -}) { - const tournament = useTournament(); - const data = useLoaderData(); - const { t } = useTranslation(["game-misc"]); - - const maxIndexThatWillBePlayedForSure = - data.match.roundMaps?.type === "PLAY_ALL" - ? bestOf - 1 - : mapCountPlayedInSetWithCertainty({ bestOf, scores }) - 1; - - const indexWithBansConsider = (realIdx: number) => { - let result = 0; - - for (const [idx, map] of (data.mapList ?? []).entries()) { - if (idx === realIdx) { - break; - } - - if (map.bannedByTournamentTeamId) { - continue; - } - - result++; - } - - return result; - }; - - // TODO: this should be button when we click on it - return ( -
-
- {nullFilledArray( - Math.max(data.mapList?.length ?? 0, data.match.roundMaps?.count ?? 0), - ).map((_, i) => { - const map = data.mapList?.[i]; - - const adjustedI = indexWithBansConsider(i); - - if ( - data.matchIsOver && - !data.results[adjustedI] && - !map?.bannedByTournamentTeamId - ) { - return null; - } - - if (!map?.mode) { - return ( -
- -
- ); - } - - if (map.bannedByTournamentTeamId) { - const bannerTeamName = tournament.ctx.teams.find( - (t) => t.id === map.bannedByTournamentTeamId, - )?.name; - - return ( - - {t(`game-misc:MODE_LONG_${map.mode}`)} - - } - > -
- {t(`game-misc:MODE_SHORT_${map.mode}`)}{" "} - {t(`game-misc:STAGE_${map.stageId}`)} -
-
- Banned by {bannerTeamName} -
-
- ); - } - - return ( - {t(`game-misc:MODE_LONG_${map.mode}`)} setSelectedResultIndex?.(adjustedI)} - testId={`mode-progress-${map.mode}`} - /> - ); - })} -
-
- ); -} - -function StartedMatchTabs({ - presentational, - scores, - teams, - result, -}: { - presentational?: boolean; - scores: [number, number]; - teams: [TournamentDataTeam, TournamentDataTeam]; - result?: Result; -}) { - const user = useUser(); - const tournament = useTournament(); - const data = useLoaderData(); - const validTabs = ["rosters", "actions"]; - const [selectedTabKey, setSelectedTabKey] = useSearchParamState({ - defaultValue: "rosters", - name: "tab", - revive: (value) => (validTabs.includes(value) ? value : null), - }); - - const currentPosition = scores[0] + scores[1]; - - const matchActionsKey = () => - [ - data.match.id, - tournamentTeamToActiveRosterUserIds( - teams[0], - tournament.minMembersPerTeam, - ), - tournamentTeamToActiveRosterUserIds( - teams[1], - tournament.minMembersPerTeam, - ), - result?.participants - .map((p) => `${p.userId}-${p.tournamentTeamId}`) - .join(","), - result?.opponentOnePoints, - result?.opponentTwoPoints, - data.results.length, - ].join("-"); - - return ( - - setSelectedTabKey(String(key))} - className={styles.matchTabs} - > - - Rosters - - {presentational ? "Score" : "Actions"} - - - - - - - - - - - - - ); -} - -function ActionSectionWrapper({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -function ScreenBanIcons({ banned }: { banned: boolean }) { - const { t } = useTranslation(["weapons"]); - - return ( -
- {banned ? : } - {t(`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`)} -
- ); -} - -function EndSetPopover({ - teams, -}: { - teams: [TournamentDataTeam, TournamentDataTeam]; -}) { - const { t } = useTranslation(["tournament"]); - const [selectedWinner, setSelectedWinner] = React.useState< - number | null | undefined - >(undefined); - - return ( - - {t("tournament:match.action.endSet")} - - } - > -
-
- - - - - - - -
- - - - - {t("tournament:match.action.confirmEndSet")} - -
-
- ); -} diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.module.css b/app/features/tournament-match/components/TournamentMatchAdminTab.module.css new file mode 100644 index 000000000..acf69f071 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchAdminTab.module.css @@ -0,0 +1,103 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--s-6); +} + +.castSection { + display: flex; + flex-direction: column; + gap: var(--s-4); + align-items: flex-start; +} + +.castLabelRow { + display: flex; + align-items: center; + gap: var(--s-2); + + & label { + text-box: trim-both cap alphabetic; + } +} + +.castEmptyHint { + color: var(--color-text-high); + font-size: var(--font-xs); + margin: 0; +} + +.lockRow { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.buttonRow { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + align-items: center; + justify-content: center; +} + +.editSection { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.resultList { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.resultRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-2); + padding: var(--s-2) var(--s-3); + background-color: var(--color-bg-higher); + border-radius: var(--radius-box); +} + +.resultRowEditing { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: var(--s-3); + background-color: var(--color-bg-higher); + border-radius: var(--radius-box); +} + +.mapIndex { + font-weight: var(--weight-semi); + margin-right: var(--s-2); +} + +.winnerName { + color: var(--color-text-high); +} + +.rosterColumns { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--s-3); + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} + +.teamFieldset { + border: 2px solid var(--color-border); + border-radius: var(--radius-box); + padding: var(--s-2); + + & legend { + padding: 0 var(--s-2); + font-weight: var(--weight-semi); + } +} diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx new file mode 100644 index 000000000..8adc94174 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx @@ -0,0 +1,585 @@ +import { Ban, Lock, LockOpen, RotateCcw, SquarePen } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Form, useFetcher } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouPopover } from "~/components/elements/Popover"; +import { SendouTabPanel } from "~/components/elements/Tabs"; +import { toastQueue } from "~/components/elements/Toast"; +import { InfoPopover } from "~/components/InfoPopover"; +import { Label } from "~/components/Label"; +import { TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { SubmitButton } from "~/components/SubmitButton"; +import { TournamentMatchStatus } from "~/db/tables"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; +import { OrganizerMatchMapListDialog } from "./OrganizerMatchMapListDialog"; +import styles from "./TournamentMatchAdminTab.module.css"; + +const NOT_CASTED_VALUE = "null"; + +export function TournamentMatchAdminTab({ + data, +}: { + data: TournamentMatchLoaderData; +}) { + const user = useUser(); + const tournament = useTournament(); + + const opponentOneId = data.match.opponentOne?.id; + const opponentTwoId = data.match.opponentTwo?.id; + const teamOne = opponentOneId + ? tournament.teamById(opponentOneId) + : undefined; + const teamTwo = opponentTwoId + ? tournament.teamById(opponentTwoId) + : undefined; + + const scoreOne = data.match.opponentOne?.score ?? 0; + const scoreTwo = data.match.opponentTwo?.score ?? 0; + const matchIsOngoing = scoreOne > 0 || scoreTwo > 0; + + const isOrganizer = tournament.isOrganizer(user); + const canReopen = + isOrganizer && + data.matchIsOver && + tournament.matchCanBeReopened(data.match.id); + const canEndSet = + isOrganizer && !data.matchIsOver && data.match.startedAt !== null; + + const topActionsVisible = !!teamOne && !!teamTwo; + const castSectionVisible = !data.matchIsOver; + const editScoresVisible = + isOrganizer && !!teamOne && !!teamTwo && data.results.length > 0; + + return ( + +
+ {topActionsVisible ? ( +
+ + {canReopen ? : null} + {canEndSet ? : null} +
+ ) : null} + {castSectionVisible ? ( + + ) : null} + {editScoresVisible ? ( + + ) : null} +
+
+ ); +} + +const LOCKING_INFO = + "You can lock the match to indicate that it should not be started before the cast is ready. Match being locked prevents score reporting and hides the map list till the organizer/streamer unlocks it."; +const SET_AS_CASTED_INFO = + "Select the Twitch account that is currently casting this match. It is then indicated in the bracket view."; + +function AdminCastSection({ + matchIsOngoing, + matchId, + matchStatus, +}: { + matchIsOngoing: boolean; + matchId: number; + matchStatus: number; +}) { + const tournament = useTournament(); + + const castTwitchAccounts = tournament.ctx.castTwitchAccounts ?? []; + const castedMatchesInfo = tournament.ctx.castedMatchesInfo; + const currentlyCastedOn = + castedMatchesInfo?.castedMatches.find((cm) => cm.matchId === matchId) + ?.twitchAccount ?? null; + const isLocked = + castedMatchesInfo?.lockedMatches?.some((lm) => lm.matchId === matchId) ?? + false; + + const canLock = + (matchStatus === TournamentMatchStatus.Locked || + matchStatus === TournamentMatchStatus.Waiting) && + !isLocked; + const canUnlock = !matchIsOngoing && isLocked; + + return ( +
+
+ + {SET_AS_CASTED_INFO} +
+ {castTwitchAccounts.length === 0 ? ( +

+ Configure streaming channels on the tournament admin page to enable + casting. +

+ ) : ( + <> + + {canLock || canUnlock ? ( + + ) : null} + + )} +
+ ); +} + +function CastChannelChipRadio({ + matchId, + accounts, + currentlyCastedOn, +}: { + matchId: number; + accounts: string[]; + currentlyCastedOn: string | null; +}) { + const fetcher = useFetcher(); + const previousStateRef = React.useRef(fetcher.state); + + React.useEffect(() => { + if ( + previousStateRef.current !== "idle" && + fetcher.state === "idle" && + !(fetcher.data as { error?: unknown } | undefined)?.error + ) { + toastQueue.add( + { message: "Cast channel updated", variant: "success" }, + { timeout: 5000 }, + ); + } + previousStateRef.current = fetcher.state; + }, [fetcher.state, fetcher.data]); + + const selectedValue = currentlyCastedOn ?? NOT_CASTED_VALUE; + + const handleChange = (value: string) => { + if (value === selectedValue) return; + fetcher.submit( + { _action: "SET_AS_CASTED", twitchAccount: value }, + { method: "post" }, + ); + }; + + return ( + + + Not casted + + {accounts.map((account) => ( + + {account} + + ))} + + ); +} + +function LockToggleButton({ + isLocked, + twitchAccount, +}: { + isLocked: boolean; + twitchAccount: string | null; +}) { + return ( +
+ {isLocked ? ( + } + testId="cast-info-submit-button" + > + Unlock + + ) : ( + <> + + } + isDisabled={!twitchAccount} + testId="cast-info-submit-button" + > + Lock to be casted + + + )} + {LOCKING_INFO} +
+ ); +} + +function ReopenMatchButton() { + const { t } = useTranslation(["tournament"]); + + return ( +
+ } + testId="reopen-match-button" + > + {t("tournament:match.action.reopenMatch")} + +
+ ); +} + +function EndSetPopover({ + teams, +}: { + teams: [TournamentDataTeam, TournamentDataTeam]; +}) { + const { t } = useTranslation(["tournament"]); + const [selectedWinner, setSelectedWinner] = React.useState< + number | null | undefined + >(undefined); + + return ( + } + > + {t("tournament:match.action.endSet")} + + } + > +
+
+ + + + + + + +
+ + + + + {t("tournament:match.action.confirmEndSet")} + +
+
+ ); +} + +function EditReportedScoresSection({ + data, + teams, +}: { + data: TournamentMatchLoaderData; + teams: [TournamentDataTeam, TournamentDataTeam]; +}) { + const tournament = useTournament(); + + const withPoints = tournament.bracketByIdxOrDefault( + tournament.matchIdToBracketIdx(data.match.id) ?? 0, + ).collectResultsWithPoints; + + return ( +
+ +
+ {data.results.map((result, index) => ( + + ))} +
+
+ ); +} + +function EditReportedScoreRow({ + index, + result, + teams, + withPoints, +}: { + index: number; + result: TournamentMatchLoaderData["results"][number]; + teams: [TournamentDataTeam, TournamentDataTeam]; + withPoints: boolean; +}) { + const tournament = useTournament(); + const fetcher = useFetcher(); + const [editing, setEditing] = React.useState(false); + + const winnerName = + result.winnerTeamId === teams[0].id ? teams[0].name : teams[1].name; + const pointsText = (() => { + if ( + result.opponentOnePoints === null || + result.opponentTwoPoints === null + ) { + return ""; + } + if (result.opponentOnePoints === 100 || result.opponentTwoPoints === 100) { + return " (KO)"; + } + return ` (${result.opponentOnePoints}p-${result.opponentTwoPoints}p)`; + })(); + + if (!editing) { + return ( +
+
+ Map {index + 1} + + {winnerName} won{pointsText} + +
+ } + variant="outlined" + size="small" + onPress={() => setEditing(true)} + data-testid={`edit-result-${index}-button`} + > + Edit + +
+ ); + } + + return ( + setEditing(false)} + index={index} + /> + ); +} + +function EditReportedScoreForm({ + fetcher, + result, + teams, + withPoints, + minMembersPerTeam, + onCancel, + index, +}: { + fetcher: ReturnType; + result: TournamentMatchLoaderData["results"][number]; + teams: [TournamentDataTeam, TournamentDataTeam]; + withPoints: boolean; + minMembersPerTeam: number; + onCancel: () => void; + index: number; +}) { + const initialRosters = React.useMemo<[number[], number[]]>(() => { + return [ + result.participants + .filter((p) => p.tournamentTeamId === teams[0].id) + .map((p) => p.userId), + result.participants + .filter((p) => p.tournamentTeamId === teams[1].id) + .map((p) => p.userId), + ]; + }, [result, teams]); + + const [checkedPlayers, setCheckedPlayers] = + React.useState<[number[], number[]]>(initialRosters); + const [points, setPoints] = React.useState<[number, number]>([ + result.opponentOnePoints ?? 0, + result.opponentTwoPoints ?? 0, + ]); + + const rosterValid = checkedPlayers.every( + (team) => team.length === minMembersPerTeam, + ); + + const pointsValid = (() => { + if (!withPoints) return true; + if (points[0] === points[1]) return false; + if (points[0] === 100 && points[1] !== 0) return false; + if (points[1] === 100 && points[0] !== 0) return false; + const originalWinnerWasOne = + (result.opponentOnePoints ?? 0) > (result.opponentTwoPoints ?? 0); + return originalWinnerWasOne ? points[0] > points[1] : points[1] > points[0]; + })(); + + const formValid = rosterValid && pointsValid; + + const togglePlayer = (teamIdx: 0 | 1, userId: number) => { + setCheckedPlayers((prev) => { + const next: [number[], number[]] = [prev[0].slice(), prev[1].slice()]; + if (next[teamIdx].includes(userId)) { + next[teamIdx] = next[teamIdx].filter((id) => id !== userId); + } else { + next[teamIdx] = [...next[teamIdx], userId]; + } + return next; + }); + }; + + return ( + +
+ {teams.map((team, teamIdx) => ( +
+ {team.name} +
+ {team.members.map((member) => { + const checked = checkedPlayers[teamIdx as 0 | 1].includes( + member.userId, + ); + return ( + + ); + })} +
+ {withPoints ? ( +
+ + { + const value = Number(e.target.value); + setPoints((prev) => { + const next: [number, number] = [prev[0], prev[1]]; + next[teamIdx as 0 | 1] = Number.isFinite(value) + ? value + : 0; + return next; + }); + }} + /> +
+ ) : null} +
+ ))} +
+ + + {withPoints ? ( + + ) : null} +
+ + Save + + + Cancel + +
+
+ ); +} diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx index 7cb00b6e3..28a4a42a8 100644 --- a/app/features/tournament-match/components/TournamentMatchBanner.tsx +++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx @@ -1,5 +1,5 @@ import { differenceInMinutes } from "date-fns"; -import { Check, Users, X } from "lucide-react"; +import { Check, Lock, Users, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { IconBanner, @@ -10,6 +10,10 @@ import { import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow"; import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { + isLeagueRoundLocked, + resolveLeagueRoundStartDate, +} from "~/features/tournament/tournament-utils"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; @@ -36,6 +40,11 @@ export function TournamentMatchBanner({ tournament, ); + const leagueRoundLocked = isLeagueRoundLocked(tournament, data.match.roundId); + const leagueRoundStartDate = leagueRoundLocked + ? resolveLeagueRoundStartDate(tournament, data.match.roundId) + : null; + const pickBanBanner = resolvePickBanBanner(data, tournament, t); const activeRosterByTeamId = (tournamentTeamId: number) => { @@ -53,7 +62,19 @@ export function TournamentMatchBanner({ return ( - {teamsMissingActiveRoster.length > 0 ? ( + {leagueRoundLocked ? ( + } + header={t("tournament:match.leagueLocked.header")} + subtitle={ + leagueRoundStartDate + ? t("tournament:match.leagueLocked.subtitle", { + date: leagueRoundStartDate.toLocaleDateString(), + }) + : undefined + } + /> + ) : teamsMissingActiveRoster.length > 0 ? ( } header={t("tournament:match.activeRosterMissing.header")} diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx index 6a310ca93..abc7b57cf 100644 --- a/app/features/tournament-match/components/TournamentMatchTabs.tsx +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -7,6 +7,7 @@ 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 { isLeagueRoundLocked } from "~/features/tournament/tournament-utils"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { groupNumberToLetters, @@ -18,6 +19,7 @@ import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.s import { resolveHostingTeam, resolveRoomPass } from "../tournament-match-utils"; import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab"; import { TournamentMatchActionTab } from "./TournamentMatchActionTab"; +import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab"; import { TournamentMatchPickBanTab } from "./TournamentMatchPickBanTab"; export function TournamentMatchTabs({ @@ -74,6 +76,11 @@ export function TournamentMatchTabs({ const hasPickBanSetup = Boolean(data.match.roundMaps?.pickBan) && !!pickBanTeams; + const isAdminEligible = + tournament.isOrganizerOrStreamer(user) && !tournament.ctx.isFinalized; + + const leagueRoundLocked = isLeagueRoundLocked(tournament, data.match.roundId); + const tabs = resolveVisibleTabs({ matchIsOver: data.matchIsOver, canReportScore, @@ -82,6 +89,8 @@ export function TournamentMatchTabs({ hasMissingActiveRoster, isPickBanStep, hasPickBanSetup, + isAdminEligible, + leagueRoundLocked, }); const userTeamId = tournament.teamMemberOfByUser(user)?.id; @@ -119,6 +128,7 @@ export function TournamentMatchTabs({ /> ) : null ) : null} + {tabs.includes("admin") ? : null} ); } @@ -376,6 +386,8 @@ function resolveVisibleTabs({ hasMissingActiveRoster, isPickBanStep, hasPickBanSetup, + isAdminEligible, + leagueRoundLocked, }: { matchIsOver: boolean; canReportScore: boolean; @@ -384,25 +396,33 @@ function resolveVisibleTabs({ hasMissingActiveRoster: boolean; isPickBanStep: boolean; hasPickBanSetup: boolean; + isAdminEligible: boolean; + leagueRoundLocked: boolean; }) { - const tabs: Array<"join" | "rosters" | "pickBan" | "action" | "result"> = []; + const tabs: Array< + "join" | "rosters" | "pickBan" | "action" | "result" | "admin" + > = []; if (matchIsOver) { tabs.push("result"); } - if (!matchIsOver && isParticipant) { + if (!matchIsOver && isParticipant && !leagueRoundLocked) { tabs.push("join"); } tabs.push("rosters"); if ( - isPickBanStep || - (canReportScore && hasCurrentMap && !hasMissingActiveRoster) + !leagueRoundLocked && + (isPickBanStep || + (canReportScore && hasCurrentMap && !hasMissingActiveRoster)) ) { tabs.push("action"); } if (hasPickBanSetup) { tabs.push("pickBan"); } + if (isAdminEligible) { + tabs.push("admin"); + } return tabs; } diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts index 349942302..2629f0411 100644 --- a/app/features/tournament/tournament-utils.server.ts +++ b/app/features/tournament/tournament-utils.server.ts @@ -96,9 +96,11 @@ export function endDroppedTeamMatches({ { id: match.id, opponent1: { + score: match.opponent1.score, result: winnerTeamId === match.opponent1.id ? "win" : "loss", }, opponent2: { + score: match.opponent2.score, result: winnerTeamId === match.opponent2.id ? "win" : "loss", }, }, diff --git a/app/utils/flip.ts b/app/utils/flip.ts deleted file mode 100644 index 91cec981e..000000000 --- a/app/utils/flip.ts +++ /dev/null @@ -1,19 +0,0 @@ -// https://github.com/aholachek/react-flip-toolkit/issues/95#issuecomment-546101332 -/** - * Thin wrapper around Element.animate() that returns a Promise - * @param el Element to animate - * @param keyframes The keyframes to use when animating - * @param options Either the duration of the animation or an options argument detailing how the animation should be performed - * @returns A promise that will resolve after the animation completes or is cancelled - */ -export function animate( - el: HTMLElement, - keyframes: Keyframe[] | PropertyIndexedKeyframes, - options?: number | KeyframeAnimationOptions, -): Promise { - return new Promise((resolve) => { - const anim = el.animate(keyframes, options); - anim.addEventListener("finish", () => resolve()); - anim.addEventListener("cancel", () => resolve()); - }); -} diff --git a/db-test.sqlite3 b/db-test.sqlite3 index e0bc26092..54cd492f1 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index 9b95e4b8d..a72f332e2 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index fb2bc317b..78019332d 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 403e79c79..8048b98c8 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index ed968f759..a1eb3f81a 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 0453c5be8..7f13b4f46 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 5e8f4b765..37ea286ab 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index b73794217..42aba0250 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 7c1149398..98afa8af1 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 90eebb1b0..c2c924e35 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index d5b7fa1ae..a8daf6764 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index 89c5ca222..7a40c9970 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/locales/da/q.json b/locales/da/q.json index 41a0f5793..2febbfb2e 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 30a30ee58..cd3a74b4c 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -113,6 +113,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Spil alle runder {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Annuller sidste score", "match.action.reopenMatch": "Genåbn kamp", "match.action.endSet": "", diff --git a/locales/de/q.json b/locales/de/q.json index 258dec4a1..d15cd2583 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index afc9e28c5..1d9d2f86b 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -113,6 +113,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Letztes Ergebnis widerrufen", "match.action.reopenMatch": "Match erneut öffnen", "match.action.endSet": "", diff --git a/locales/en/q.json b/locales/en/q.json index 23afee63b..1928269fd 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "Continue queueing with the group of {{count}}?", "match.rematch.vote.yes": "Yes, continue", "match.rematch.vote.no": "No, I'm done", + "match.rematch.vote.noConfirm": "Vote no? You can't change your vote afterwards.", "match.rematch.declined": "You declined to continue", "match.rematch.fizzled": "Nobody wanted to continue", "match.rematch.waitingCaptain": "Waiting for the captain to choose whether to re-queue", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 4aba5d77d..2b99eceaf 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -113,6 +113,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})", "match.activeRosterMissing.header": "Active roster needed", "match.activeRosterMissing.subtitle": "Waiting on {{teams}}", + "match.leagueLocked.header": "Waiting for league round to start", + "match.leagueLocked.subtitle": "Round playable from {{date}} onwards", "match.action.undoLastScore": "Undo last score", "match.action.reopenMatch": "Reopen match", "match.action.endSet": "End set", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 52055acad..e8c54cbc4 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 14902aeda..a27291394 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -115,6 +115,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Anular resultado previo", "match.action.reopenMatch": "Reabrir partido", "match.action.endSet": "Finalizar set", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 03fd44a50..29eea5d02 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 8e605e32f..468f76854 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -115,6 +115,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jugar todos los {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Anular resultado previo", "match.action.reopenMatch": "Reabrir partido", "match.action.endSet": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 3723517e4..8b89b03dd 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index caab77fbb..7d8727c43 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -115,6 +115,8 @@ "match.score.playAll": "", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Annuler le dernier score", "match.action.reopenMatch": "Rouvrir le match", "match.action.endSet": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 9ad1d90fe..fb0858df5 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 9d1eeef6c..f9c8b96c5 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -115,6 +115,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Tous jouer {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Annuler le dernier score", "match.action.reopenMatch": "Rouvrir le match", "match.action.endSet": "", diff --git a/locales/he/q.json b/locales/he/q.json index 95f5fb455..e9a740422 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 448c2d746..cf0bdb0a8 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -115,6 +115,8 @@ "match.score.playAll": "", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "בטלו את התוצאה האחרונה", "match.action.reopenMatch": "פתיחה מחדש של הקרב", "match.action.endSet": "", diff --git a/locales/it/q.json b/locales/it/q.json index 0be46ede2..0a7d34d72 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 16c7579ab..5e946bb97 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -115,6 +115,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Play all {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Annulla ultimo punteggio", "match.action.reopenMatch": "Riapri match", "match.action.endSet": "", diff --git a/locales/ja/q.json b/locales/ja/q.json index 7d40979b5..69eafa977 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index a5bc1715e..abfa67b61 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -109,6 +109,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全てプレイする {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "最後のスコアをやりなおす", "match.action.reopenMatch": "対戦を再度開く", "match.action.endSet": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index 258dec4a1..d15cd2583 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 49346c7de..d9ca722dd 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -109,6 +109,8 @@ "match.score.playAll": "", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "", "match.action.reopenMatch": "", "match.action.endSet": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 258dec4a1..d15cd2583 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 440590802..3c17fa456 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -113,6 +113,8 @@ "match.score.playAll": "", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "", "match.action.reopenMatch": "", "match.action.endSet": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 258dec4a1..d15cd2583 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index aed0c008d..97cf2ce3b 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -117,6 +117,8 @@ "match.score.playAll": "", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "", "match.action.reopenMatch": "", "match.action.endSet": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 59ba8c466..534f13434 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 56ebb5997..b20356151 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -115,6 +115,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Jogar todas {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Desfazer última pontuação", "match.action.reopenMatch": "Reabrir partida", "match.action.endSet": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index 9dbc80cf0..23d9650b0 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index f85c964fa..78fff3b2c 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -117,6 +117,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (Играть все {{bestOf}})", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "Отменить последний результат", "match.action.reopenMatch": "Открыть матч заново", "match.action.endSet": "", diff --git a/locales/zh/q.json b/locales/zh/q.json index 76bf9790d..d03913b8f 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -211,6 +211,7 @@ "match.rematch.prompt": "", "match.rematch.vote.yes": "", "match.rematch.vote.no": "", + "match.rematch.vote.noConfirm": "", "match.rematch.declined": "", "match.rematch.fizzled": "", "match.rematch.waitingCaptain": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 3d728c05e..d126d8201 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -109,6 +109,8 @@ "match.score.playAll": "{{scoreOne}}-{{scoreTwo}} (全部 {{bestOf}} 场)", "match.activeRosterMissing.header": "", "match.activeRosterMissing.subtitle": "", + "match.leagueLocked.header": "", + "match.leagueLocked.subtitle": "", "match.action.undoLastScore": "撤销上次比分", "match.action.reopenMatch": "重新开始对战", "match.action.endSet": "",