From ee6e92967bc295676c00991d6dec9c2efd87a5cc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 11 May 2026 20:04:41 +0300 Subject: [PATCH] Allow reporting weapons after tournament has finalized --- .../tournament-bracket/core/Tournament.ts | 11 +++++ .../actions/to.$id.matches.$mid.server.ts | 10 ++++- .../components/TournamentMatchActionTab.tsx | 4 ++ .../tournament-match/match-page-context.tsx | 2 +- .../tournament/tournament-utils.test.ts | 42 +++++++++++++++++++ app/features/tournament/tournament-utils.ts | 25 +++++++++++ 6 files changed, 91 insertions(+), 3 deletions(-) diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 8bd834934..4f0ed86f4 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -10,6 +10,7 @@ import { import { modesIncluded, sortTeamsBySeeding, + tournamentInWeaponReportingWindow, tournamentIsRanked, } from "~/features/tournament/tournament-utils"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; @@ -895,6 +896,16 @@ export class Tournament { return this.registrationClosesAt > new Date(); } + /** Can participants submit/undo their own weapon reports right now? + * Always open while the tournament is running; once finalized it stays open only for tournaments + * whose startTime is inside the current-season-plus-adjacent-off-season window. */ + get weaponReportingOpen() { + if (!this.ctx.isFinalized) return true; + return tournamentInWeaponReportingWindow({ + tournamentStartTime: this.ctx.startTime, + }); + } + /** * Does this tournament have autonomous subs feature enabled? * If enabled, teams can add members to their roster while tournament is in progress without having to request the organizer to do it. 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 21dfaf7ba..4822656fd 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 @@ -774,7 +774,10 @@ export const action: ActionFunction = async ({ params, request }) => { (p) => p.id === user.id, ); errorToastIfFalsy(isMemberOfATeamInTheMatch, "Unauthorized"); - errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); + errorToastIfFalsy( + tournament.weaponReportingOpen, + "Weapon reporting is closed", + ); await ReportedWeaponRepository.upsertOneTournament({ tournamentMatchId: matchId, @@ -790,7 +793,10 @@ export const action: ActionFunction = async ({ params, request }) => { (p) => p.id === user.id, ); errorToastIfFalsy(isMemberOfATeamInTheMatch, "Unauthorized"); - errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); + errorToastIfFalsy( + tournament.weaponReportingOpen, + "Weapon reporting is closed", + ); await ReportedWeaponRepository.deleteByUserMapIndexTournament({ tournamentMatchId: matchId, diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx index 77d8bf766..f9c9ca885 100644 --- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx @@ -38,6 +38,7 @@ export function TournamentMatchActionTab({ const weaponReport = useTournamentWeaponReport({ data, viewerUserId: user?.id, + weaponReportingOpen: tournament.weaponReportingOpen, }); if (!currentMap) { @@ -152,9 +153,11 @@ export function TournamentMatchActionTab({ function useTournamentWeaponReport({ data, viewerUserId, + weaponReportingOpen, }: { data: TournamentMatchLoaderData; viewerUserId: number | undefined; + weaponReportingOpen: boolean; }) { const playOrderMaps = (data.mapList ?? []).filter( (m) => !m.bannedByTournamentTeamId, @@ -177,6 +180,7 @@ function useTournamentWeaponReport({ }); if (viewerUserId === undefined) return null; + if (!weaponReportingOpen) return null; const isParticipant = data.match.players.some((p) => p.id === viewerUserId); if (!isParticipant) return null; diff --git a/app/features/tournament-match/match-page-context.tsx b/app/features/tournament-match/match-page-context.tsx index dec3b4f0c..64241afa5 100644 --- a/app/features/tournament-match/match-page-context.tsx +++ b/app/features/tournament-match/match-page-context.tsx @@ -100,7 +100,7 @@ export function MatchPageProvider({ user, }), canReportWeapons: - isParticipant && !tournament.ctx.isFinalized && hasReportedMaps, + isParticipant && tournament.weaponReportingOpen && hasReportedMaps, canJoin: data.canJoin, hasCurrentMap: Boolean(currentMap), hasMissingActiveRoster: teamsMissingActiveRoster.length > 0, diff --git a/app/features/tournament/tournament-utils.test.ts b/app/features/tournament/tournament-utils.test.ts index 90ea09166..40e8a2b47 100644 --- a/app/features/tournament/tournament-utils.test.ts +++ b/app/features/tournament/tournament-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { CastedMatchesInfo } from "~/db/tables"; +import * as Seasons from "../mmr/core/Seasons"; import type { ParsedBracket } from "../tournament-bracket/core/Progression"; import { compareTeamsForOrdering, @@ -7,6 +8,7 @@ import { getBracketProgressionLabel, sortTeamsBySeeding, type TeamForOrdering, + tournamentInWeaponReportingWindow, updatedCastedMatchesInfo, } from "./tournament-utils"; @@ -642,3 +644,43 @@ describe("updatedCastedMatchesInfo", () => { }); }); }); + +describe("tournamentInWeaponReportingWindow", () => { + const anchorSeason = Seasons.list[2]!; + const previousSeason = Seasons.list[1]!; + + const dateInside = (range: { starts: Date; ends: Date }) => + new Date((range.starts.getTime() + range.ends.getTime()) / 2); + + const inSeasonNow = dateInside(anchorSeason); + const offSeasonNow = new Date( + (previousSeason.ends.getTime() + anchorSeason.starts.getTime()) / 2, + ); + + it("allows tournaments started in the off-season before current season (in-season)", () => { + expect( + tournamentInWeaponReportingWindow({ + tournamentStartTime: offSeasonNow, + now: inSeasonNow, + }), + ).toBe(true); + }); + + it("rejects tournaments started before the previous season ended (in-season)", () => { + expect( + tournamentInWeaponReportingWindow({ + tournamentStartTime: dateInside(previousSeason), + now: inSeasonNow, + }), + ).toBe(false); + }); + + it("allows tournaments started during the previous season (off-season)", () => { + expect( + tournamentInWeaponReportingWindow({ + tournamentStartTime: dateInside(previousSeason), + now: offSeasonNow, + }), + ).toBe(true); + }); +}); diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index f6dc8ebb3..3e0a0bb6c 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -159,6 +159,31 @@ export function tournamentIsRanked({ return isSetAsRanked ?? true; } +/** + * Whether a tournament's startTime falls inside the active weapon-reporting window + * for late (post-finalization) reporting. + * + * - In-season: window is `(previousSeason.ends, now]` — current season plus the off-season immediately before it. + * - Off-season: window is `[previousSeason.starts, now]` — previous full season plus the current off-season. + */ +export function tournamentInWeaponReportingWindow({ + tournamentStartTime, + now = new Date(), +}: { + tournamentStartTime: Date; + now?: Date; +}) { + const previousSeason = Seasons.previous(now); + if (!previousSeason) return true; + + const currentSeason = Seasons.current(now); + const windowStart = currentSeason + ? previousSeason.ends + : previousSeason.starts; + + return tournamentStartTime > windowStart; +} + export function resolveLeagueRoundStartDate( tournament: TournamentClass, roundId: number,