From 61d189f1feaa30ea0abd6b38707990b6b780dd6d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:36:21 +0300 Subject: [PATCH] Streamline and fix tournament team export 0b download --- app/components/MobileNav.module.css | 5 + app/components/layout/index.module.css | 5 + .../components/ExportDialog.tsx | 105 +++++++++--------- .../core/Tournament.test.ts | 33 ++++++ .../tournament-bracket/core/Tournament.ts | 27 +++-- ...-export-not-checked-in-starting-bracket.md | 5 + 6 files changed, 119 insertions(+), 61 deletions(-) create mode 100644 changelog/2026-09-27-export-not-checked-in-starting-bracket.md diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index 8425dcb3a..e71c49b6f 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -18,6 +18,11 @@ var(--layout-nav-height) + env(safe-area-inset-bottom) ); + + /* snapshots paint above the top layer, which would lift it over an open modal's backdrop */ + :root:has(:modal) & { + view-transition-name: none; + } } @media screen and (min-width: 600px) { diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index 425f1bf2b..34ad211a6 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -19,6 +19,11 @@ z-index: 10; /* captured on its own so animating content slides under it instead of over it */ view-transition-name: layout-header; + + /* snapshots paint above the top layer, which would lift it over an open modal's backdrop */ + :root:has(:modal) & { + view-transition-name: none; + } } ::view-transition-group(layout-header) { diff --git a/app/features/tournament-admin/components/ExportDialog.tsx b/app/features/tournament-admin/components/ExportDialog.tsx index ee61e1ab0..313205757 100644 --- a/app/features/tournament-admin/components/ExportDialog.tsx +++ b/app/features/tournament-admin/components/ExportDialog.tsx @@ -90,7 +90,7 @@ export function ExportDialog({ const [format, setFormat] = React.useState("list"); const [status, setStatus] = React.useState("all"); - const [bracketIdx, setBracketIdx] = React.useState(null); + const [bracketIdx, setBracketIdx] = React.useState(0); const [sort, setSort] = React.useState("seed"); const [roster, setRoster] = React.useState("full"); const [fields, setFields] = React.useState>( @@ -108,35 +108,37 @@ export function ExportDialog({ return next; }); + const selectedBracket = tournament.bracketsMeta[bracketIdx]; + const bracketRequiresOwnCheckIn = + selectedBracket.requiresCheckIn && !selectedBracket.isStartingBracket; + const teams = scopedAndSortedTeams({ + teams: allTeams, + status, + sort, + bracketIdx, + bracketRequiresOwnCheckIn, + bracketParticipantIds: new Set( + tournament.eligibleTeamIdsOfBracket(bracketIdx), + ), + }); + const content = buildContent({ + teams, + format, + fields, + captainsOnly: roster === "captains", + bracketIdx, + bracketRequiresOwnCheckIn, + checkedInLabel: "Checked in", + notCheckedInLabel: "Not checked in", + }); + const nothingToExportReason = + teams.length === 0 + ? "No teams match the selected bracket and status" + : fields.size === 0 || !content + ? "Nothing to export with the selected fields" + : null; + const onDownload = () => { - const selectedBracket = - bracketIdx !== null ? tournament.bracketsMeta[bracketIdx] : null; - const bracketRequiresOwnCheckIn = Boolean(selectedBracket?.requiresCheckIn); - const teams = scopedAndSortedTeams({ - teams: allTeams, - status, - sort, - bracketIdx, - bracketRequiresOwnCheckIn, - // a team belongs to a bracket whether or not it has checked in (the rest are pending check-in); - // both are scoped out of the export when they don't belong to it - bracketParticipantIds: selectedBracket - ? new Set([ - ...selectedBracket.participantTournamentTeamIds, - ...(selectedBracket.teamsPendingCheckIn ?? []), - ]) - : null, - }); - const content = buildContent({ - teams, - format, - fields, - captainsOnly: roster === "captains", - bracketIdx, - bracketRequiresOwnCheckIn, - checkedInLabel: "Checked in", - notCheckedInLabel: "Not checked in", - }); handleDownload({ filename: `participants.${format === "csv" ? "csv" : "txt"}`, content, @@ -187,17 +189,12 @@ export function ExportDialog({ {tournament.bracketsMeta.length > 1 ? ( - setBracketIdx(value === "all" ? null : Number(value)) - } - options={[ - { value: "all", label: "All brackets" }, - ...tournament.bracketsMeta.map((bracket, idx) => ({ - value: String(idx), - label: bracket.name || `#${idx}`, - })), - ]} + value={String(bracketIdx)} + onChange={(value) => setBracketIdx(Number(value))} + options={tournament.bracketsMeta.map((bracket, idx) => ({ + value: String(idx), + label: bracket.name || `#${idx}`, + }))} /> ) : null} @@ -221,9 +218,17 @@ export function ExportDialog({ }))} /> - - Download - +
+ + Download + + {nothingToExportReason ? ( +
{nothingToExportReason}
+ ) : null} +
); @@ -264,10 +269,10 @@ function RadioRow({ function hasActiveCheckIn( team: TournamentTeamFull, - bracketIdx: number | null, + bracketIdx: number, bracketRequiresOwnCheckIn: boolean, ) { - if (bracketIdx !== null && bracketRequiresOwnCheckIn) { + if (bracketRequiresOwnCheckIn) { return team.checkIns.some( (checkIn) => checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut, ); @@ -293,12 +298,12 @@ export function scopedAndSortedTeams({ teams: TournamentTeamFull[]; status: ExportStatus; sort: ExportSort; - bracketIdx: number | null; + bracketIdx: number; bracketRequiresOwnCheckIn: boolean; - bracketParticipantIds: Set | null; + bracketParticipantIds: Set; }) { const filtered = teams.filter((team) => { - if (bracketParticipantIds && !bracketParticipantIds.has(team.id)) { + if (!bracketParticipantIds.has(team.id)) { return false; } switch (status) { @@ -333,7 +338,7 @@ function teamFieldValue( opts: { checkedInLabel: string; notCheckedInLabel: string; - bracketIdx: number | null; + bracketIdx: number; bracketRequiresOwnCheckIn: boolean; }, ) { @@ -389,7 +394,7 @@ function buildContent({ format: ExportFormat; fields: Set; captainsOnly: boolean; - bracketIdx: number | null; + bracketIdx: number; bracketRequiresOwnCheckIn: boolean; checkedInLabel: string; notCheckedInLabel: string; diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index 59ea8ada4..7c92ee7d7 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -449,6 +449,39 @@ describe("Adjusting team starting bracket", () => { }); }); +describe("eligibleTeamIdsOfBracket", () => { + const notCheckedIn = (teamId: number) => + tournamentCtxTeam(teamId, { checkIns: [] }); + + test("includes teams not checked in for the starting bracket", () => { + const tournament = testTournament({ + ctx: { + teams: [tournamentCtxTeam(1), tournamentCtxTeam(2), notCheckedIn(3)], + }, + }); + + expect(tournament.eligibleTeamIdsOfBracket(0)).toEqual([1, 2, 3]); + }); + + test("includes only teams starting in the bracket when there are many starting brackets", () => { + const tournament = testTournament({ + ctx: { + teams: [ + tournamentCtxTeam(1, { startingBracketIdx: 0 }), + tournamentCtxTeam(2, { startingBracketIdx: 1 }), + notCheckedIn(3), + ], + settings: { + bracketProgression: progressions.manyStartBrackets, + }, + }, + }); + + expect(tournament.eligibleTeamIdsOfBracket(0)).toEqual([1, 3]); + expect(tournament.eligibleTeamIdsOfBracket(1)).toEqual([2]); + }); +}); + describe("League divisions", () => { const leagueTournament = (isLeague = true) => testTournament({ diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 2be0016cd..3efe6ed55 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -284,23 +284,28 @@ export class Tournament { } /** Teams that can play in the bracket: its participants plus the ones still pending check-in. */ - eligibleTeamsCountOfBracket(bracketIdx: number) { + eligibleTeamIdsOfBracket(bracketIdx: number) { const bracket = this.bracketsMeta[bracketIdx]; if (bracket.sources) { - return ( - (bracket.teamsPendingCheckIn ?? []).length + - bracket.participantTournamentTeamIds.length - ); + return [ + ...bracket.participantTournamentTeamIds, + ...(bracket.teamsPendingCheckIn ?? []), + ]; } - if (!this.isMultiStartingBracket) { - return this.ctx.teams.length; - } + const teams = this.isMultiStartingBracket + ? this.ctx.teams.filter( + (team) => (team.startingBracketIdx ?? 0) === bracketIdx, + ) + : this.ctx.teams; - return this.ctx.teams.filter( - (team) => (team.startingBracketIdx ?? 0) === bracketIdx, - ).length; + return teams.map((team) => team.id); + } + + /** Count of {@link eligibleTeamIdsOfBracket}. */ + eligibleTeamsCountOfBracket(bracketIdx: number) { + return this.eligibleTeamIdsOfBracket(bracketIdx).length; } /** Teams of the bracket: its participants, or every eligible team while it is a preview. */ diff --git a/changelog/2026-09-27-export-not-checked-in-starting-bracket.md b/changelog/2026-09-27-export-not-checked-in-starting-bracket.md new file mode 100644 index 000000000..4125691cb --- /dev/null +++ b/changelog/2026-09-27-export-not-checked-in-starting-bracket.md @@ -0,0 +1,5 @@ +--- +navItem: medal +type: bug +--- +Participant export's "Not checked in" option now works for the starting bracket, the redundant "All brackets" option is removed, and downloading is disabled when there's nothing to export