From 4bac10b6bb42381e618bbe053e82bd38e5833cc5 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Sat, 2 May 2026 12:19:17 +0200 Subject: [PATCH 1/2] CC-2: Improve Pairing Workflow Fixes CC-2 --- convex/_fixtures/createMockTournament.ts | 9 +- convex/_generated/api.d.ts | 6 + .../_model/common/tournamentPairingConfig.ts | 36 +- .../mutations/createTournamentCompetitor.ts | 2 +- .../getTournamentCompetitorsByTournament.ts | 6 +- convex/_model/tournamentCompetitors/table.ts | 2 +- .../_helpers/assignTables.ts | 2 +- .../generateDraftTournamentPairings.ts | 10 +- convex/_model/tournamentPairings/index.ts | 5 +- .../mutations/createTournamentPairings.ts | 3 - .../_helpers/deepenTournamentRegistration.ts | 8 + .../sortTournamentCompetitorsByName.ts | 6 + .../getTournamentRegistrationsByTournament.ts | 10 +- .../_helpers/aggregateTournamentData.ts | 2 +- .../mutations/refreshTournamentResult.ts | 2 +- .../tournaments/_helpers/deepenTournament.ts | 33 +- .../_helpers/getAvailableActions.ts | 11 +- .../_helpers/getLastVisibleRound.test.ts | 141 ++++++ .../_helpers/getLastVisibleRound.ts | 30 ++ convex/_model/tournaments/index.ts | 4 + .../tournaments/mutations/deleteTournament.ts | 2 +- .../updateTournamentPairingConfig.ts | 23 + convex/_model/utils/deleteTestTournament.ts | 2 +- convex/migrations.ts | 17 +- convex/tournaments.ts | 5 + src/api.ts | 7 + .../FactionIndicator/FactionIndicator.tsx | 13 +- .../FactionIndicator.utils.ts | 7 +- src/components/FooterBar/FooterBar.tsx | 3 +- .../PageWrapper/PageWrapper.module.scss | 16 +- src/components/PageWrapper/PageWrapper.tsx | 17 +- .../ScoreAdjustmentFields.tsx | 2 +- .../TournamentCompetitorIdentity.module.scss | 10 +- .../TournamentCompetitorIdentity.tsx | 62 +-- .../TournamentCompetitorIdentity/index.ts | 5 +- .../TournamentForm/TournamentForm.schema.ts | 12 +- .../components/PairingFields.tsx | 9 +- .../TournamentPairingConfigForm/index.ts | 6 - ...ournamentPairingGenerationForm.module.scss | 9 + .../TournamentPairingGenerationForm.tsx | 104 ++++ .../TournamentCompetitorChecklist.module.scss | 6 + .../TournamentCompetitorChecklist.tsx | 109 +++++ .../TournamentCompetitorChecklist/index.ts | 1 + .../TournamentPairingConfigFields.hooks.ts | 0 .../TournamentPairingConfigFields.module.scss | 0 .../TournamentPairingConfigFields.tsx | 10 +- .../TournamentPairingConfigFields/index.ts | 1 + .../TournamentPairingConfigForm.tsx | 16 +- .../TournamentPairingConfigForm/index.ts | 1 + .../TournamentPairingGenerationForm/index.ts | 3 + .../TournamentContextMenu.tsx | 1 - .../TournamentProvider.hooks.tsx | 2 - .../actions/useConfigureRoundAction.tsx | 49 +- .../actions/useStartAction.tsx | 25 - src/components/TournamentProvider/index.ts | 1 - ...TournamentRegistrationIdentity.module.scss | 27 ++ .../TournamentRegistrationIdentity.tsx | 56 +++ .../TournamentRegistrationIdentity/index.ts | 1 + src/hooks/useAsyncState.ts | 8 +- src/hooks/useFormDialog.tsx | 5 +- .../MatchResultDetailPage.tsx | 2 +- .../TournamentCompetitorDetailPage.tsx | 2 +- .../TournamentDetailPage.tsx | 33 +- .../TournamentCompetitorTable.module.scss | 16 + .../TournamentCompetitorTable.tsx | 106 +++++ .../TournamentCompetitorTable/index.ts | 1 + .../TournamentCompetitorsCard.module.scss} | 2 +- .../TournamentCompetitorsCard.tsx | 77 +++ .../TournamentCompetitorsCard.utils.tsx | 101 ++++ .../TournamentCompetitorsCard/index.ts | 1 + .../TournamentRankingsCard.tsx | 118 ----- .../TournamentRankingsCard.utils.tsx | 108 ----- .../TournamentRankingsCard/index.ts | 1 - .../TournamentRegistrationTable.module.scss | 16 + .../TournamentRegistrationTable.tsx | 98 ++++ .../TournamentRegistrationTable/index.ts | 1 + .../TournamentRosterCard.module.scss | 32 -- .../TournamentRosterCard.tsx | 125 ----- .../TournamentRosterCard.utils.tsx | 65 --- .../components/TournamentRosterCard/index.ts | 1 - .../TournamentPairingDetailPage.tsx | 2 +- .../TournamentPairingsPage.module.scss | 157 ------- .../TournamentPairingsPage.schema.ts | 15 +- .../TournamentPairingsPage.tsx | 444 +++++++++--------- .../TournamentPairingsPage.utils.tsx | 41 +- .../Draggable/Draggable.module.scss | 64 +++ .../components/Draggable/Draggable.tsx | 42 ++ .../components/Draggable/index.ts | 1 + .../Droppable/Droppable.module.scss | 18 + .../components/Droppable/Droppable.tsx | 40 ++ .../components/Droppable/index.ts | 1 + .../PairingsGrid/PairingsGrid.hooks.ts | 95 ++++ .../PairingsGrid/PairingsGrid.module.scss | 78 +++ .../components/PairingsGrid/PairingsGrid.tsx | 158 +++++++ .../PairingsGrid/PairingsGrid.utils.ts | 81 ++++ .../components/PairingsGrid/index.ts | 1 + src/services/tournaments.ts | 2 + src/utils/common/getRoundOptions.ts | 24 +- 98 files changed, 2041 insertions(+), 1110 deletions(-) create mode 100644 convex/_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.ts create mode 100644 convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts create mode 100644 convex/_model/tournaments/_helpers/getLastVisibleRound.ts create mode 100644 convex/_model/tournaments/mutations/updateTournamentPairingConfig.ts delete mode 100644 src/components/TournamentPairingConfigForm/index.ts create mode 100644 src/components/TournamentPairingGenerationForm/TournamentPairingGenerationForm.module.scss create mode 100644 src/components/TournamentPairingGenerationForm/TournamentPairingGenerationForm.tsx create mode 100644 src/components/TournamentPairingGenerationForm/components/TournamentCompetitorChecklist/TournamentCompetitorChecklist.module.scss create mode 100644 src/components/TournamentPairingGenerationForm/components/TournamentCompetitorChecklist/TournamentCompetitorChecklist.tsx create mode 100644 src/components/TournamentPairingGenerationForm/components/TournamentCompetitorChecklist/index.ts rename src/components/{TournamentPairingConfigForm => TournamentPairingGenerationForm/components/TournamentPairingConfigFields}/TournamentPairingConfigFields.hooks.ts (100%) rename src/components/{TournamentPairingConfigForm => TournamentPairingGenerationForm/components/TournamentPairingConfigFields}/TournamentPairingConfigFields.module.scss (100%) rename src/components/{TournamentPairingConfigForm => TournamentPairingGenerationForm/components/TournamentPairingConfigFields}/TournamentPairingConfigFields.tsx (89%) create mode 100644 src/components/TournamentPairingGenerationForm/components/TournamentPairingConfigFields/index.ts rename src/components/{ => TournamentPairingGenerationForm/components}/TournamentPairingConfigForm/TournamentPairingConfigForm.tsx (82%) create mode 100644 src/components/TournamentPairingGenerationForm/components/TournamentPairingConfigForm/index.ts create mode 100644 src/components/TournamentPairingGenerationForm/index.ts delete mode 100644 src/components/TournamentProvider/actions/useStartAction.tsx create mode 100644 src/components/TournamentRegistrationIdentity/TournamentRegistrationIdentity.module.scss create mode 100644 src/components/TournamentRegistrationIdentity/TournamentRegistrationIdentity.tsx create mode 100644 src/components/TournamentRegistrationIdentity/index.ts create mode 100644 src/pages/TournamentDetailPage/components/TournamentCompetitorTable/TournamentCompetitorTable.module.scss create mode 100644 src/pages/TournamentDetailPage/components/TournamentCompetitorTable/TournamentCompetitorTable.tsx create mode 100644 src/pages/TournamentDetailPage/components/TournamentCompetitorTable/index.ts rename src/pages/TournamentDetailPage/components/{TournamentRankingsCard/TournamentRankingsCard.module.scss => TournamentCompetitorsCard/TournamentCompetitorsCard.module.scss} (92%) create mode 100644 src/pages/TournamentDetailPage/components/TournamentCompetitorsCard/TournamentCompetitorsCard.tsx create mode 100644 src/pages/TournamentDetailPage/components/TournamentCompetitorsCard/TournamentCompetitorsCard.utils.tsx create mode 100644 src/pages/TournamentDetailPage/components/TournamentCompetitorsCard/index.ts delete mode 100644 src/pages/TournamentDetailPage/components/TournamentRankingsCard/TournamentRankingsCard.tsx delete mode 100644 src/pages/TournamentDetailPage/components/TournamentRankingsCard/TournamentRankingsCard.utils.tsx delete mode 100644 src/pages/TournamentDetailPage/components/TournamentRankingsCard/index.ts create mode 100644 src/pages/TournamentDetailPage/components/TournamentRegistrationTable/TournamentRegistrationTable.module.scss create mode 100644 src/pages/TournamentDetailPage/components/TournamentRegistrationTable/TournamentRegistrationTable.tsx create mode 100644 src/pages/TournamentDetailPage/components/TournamentRegistrationTable/index.ts delete mode 100644 src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.module.scss delete mode 100644 src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx delete mode 100644 src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.utils.tsx delete mode 100644 src/pages/TournamentDetailPage/components/TournamentRosterCard/index.ts create mode 100644 src/pages/TournamentPairingsPage/components/Draggable/Draggable.module.scss create mode 100644 src/pages/TournamentPairingsPage/components/Draggable/Draggable.tsx create mode 100644 src/pages/TournamentPairingsPage/components/Draggable/index.ts create mode 100644 src/pages/TournamentPairingsPage/components/Droppable/Droppable.module.scss create mode 100644 src/pages/TournamentPairingsPage/components/Droppable/Droppable.tsx create mode 100644 src/pages/TournamentPairingsPage/components/Droppable/index.ts create mode 100644 src/pages/TournamentPairingsPage/components/PairingsGrid/PairingsGrid.hooks.ts create mode 100644 src/pages/TournamentPairingsPage/components/PairingsGrid/PairingsGrid.module.scss create mode 100644 src/pages/TournamentPairingsPage/components/PairingsGrid/PairingsGrid.tsx create mode 100644 src/pages/TournamentPairingsPage/components/PairingsGrid/PairingsGrid.utils.ts create mode 100644 src/pages/TournamentPairingsPage/components/PairingsGrid/index.ts diff --git a/convex/_fixtures/createMockTournament.ts b/convex/_fixtures/createMockTournament.ts index 7cdcd9f0..33c0c42c 100644 --- a/convex/_fixtures/createMockTournament.ts +++ b/convex/_fixtures/createMockTournament.ts @@ -2,10 +2,12 @@ import { CurrencyCode, GameSystem, getGameSystem, - tournamentPairingConfig, } from '@ianpaschal/combat-command-game-systems/common'; import { Doc } from '../_generated/dataModel'; +import { + defaultValues as tournamentPairingConfigDefaultValues, +} from '../_model/common/tournamentPairingConfig'; import { RankingFactor } from '../_model/common/types'; const DAY_LENGTH_MS = 172800000; @@ -46,7 +48,10 @@ export const createMockTournament = ( currency: CurrencyCode.EUR, }, roundCount: 5, - pairingConfig: tournamentPairingConfig.defaultValues, + pairingConfig: { + ...tournamentPairingConfigDefaultValues, + tableCount: Math.ceil(overrides?.maxCompetitors ?? 48 / 2) ?? 1, + }, gameSystemConfig: gameSystemConfig.defaultValues, startsAt: Date.now() + (DAY_LENGTH_MS * 3), endsAt: Date.now() + (DAY_LENGTH_MS * 5), diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index cc29199b..d8e6df45 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -192,6 +192,7 @@ import type * as _model_tournamentRegistrations__helpers_deepenTournamentRegistr import type * as _model_tournamentRegistrations__helpers_getAvailableActions from "../_model/tournamentRegistrations/_helpers/getAvailableActions.js"; import type * as _model_tournamentRegistrations__helpers_getCreateSuccessMessage from "../_model/tournamentRegistrations/_helpers/getCreateSuccessMessage.js"; import type * as _model_tournamentRegistrations__helpers_getDeleteSuccessMessage from "../_model/tournamentRegistrations/_helpers/getDeleteSuccessMessage.js"; +import type * as _model_tournamentRegistrations__helpers_sortTournamentCompetitorsByName from "../_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.js"; import type * as _model_tournamentRegistrations_index from "../_model/tournamentRegistrations/index.js"; import type * as _model_tournamentRegistrations_mutations_createTournamentRegistration from "../_model/tournamentRegistrations/mutations/createTournamentRegistration.js"; import type * as _model_tournamentRegistrations_mutations_deleteTournamentRegistration from "../_model/tournamentRegistrations/mutations/deleteTournamentRegistration.js"; @@ -233,6 +234,7 @@ import type * as _model_tournaments__helpers_deepenTournament from "../_model/to import type * as _model_tournaments__helpers_extractSearchTokens from "../_model/tournaments/_helpers/extractSearchTokens.js"; import type * as _model_tournaments__helpers_getAvailableActions from "../_model/tournaments/_helpers/getAvailableActions.js"; import type * as _model_tournaments__helpers_getDisplayName from "../_model/tournaments/_helpers/getDisplayName.js"; +import type * as _model_tournaments__helpers_getLastVisibleRound from "../_model/tournaments/_helpers/getLastVisibleRound.js"; import type * as _model_tournaments__helpers_getTournamentDeep from "../_model/tournaments/_helpers/getTournamentDeep.js"; import type * as _model_tournaments__helpers_getTournamentNextRound from "../_model/tournaments/_helpers/getTournamentNextRound.js"; import type * as _model_tournaments__helpers_getTournamentPlayerUserIds from "../_model/tournaments/_helpers/getTournamentPlayerUserIds.js"; @@ -249,6 +251,7 @@ import type * as _model_tournaments_mutations_startTournamentRound from "../_mod import type * as _model_tournaments_mutations_toggleTournamentAlignmentsRevealed from "../_model/tournaments/mutations/toggleTournamentAlignmentsRevealed.js"; import type * as _model_tournaments_mutations_toggleTournamentListsRevealed from "../_model/tournaments/mutations/toggleTournamentListsRevealed.js"; import type * as _model_tournaments_mutations_updateTournament from "../_model/tournaments/mutations/updateTournament.js"; +import type * as _model_tournaments_mutations_updateTournamentPairingConfig from "../_model/tournaments/mutations/updateTournamentPairingConfig.js"; import type * as _model_tournaments_queries_getTournament from "../_model/tournaments/queries/getTournament.js"; import type * as _model_tournaments_queries_getTournamentByTournamentPairing from "../_model/tournaments/queries/getTournamentByTournamentPairing.js"; import type * as _model_tournaments_queries_getTournamentOpenRound from "../_model/tournaments/queries/getTournamentOpenRound.js"; @@ -531,6 +534,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentRegistrations/_helpers/getAvailableActions": typeof _model_tournamentRegistrations__helpers_getAvailableActions; "_model/tournamentRegistrations/_helpers/getCreateSuccessMessage": typeof _model_tournamentRegistrations__helpers_getCreateSuccessMessage; "_model/tournamentRegistrations/_helpers/getDeleteSuccessMessage": typeof _model_tournamentRegistrations__helpers_getDeleteSuccessMessage; + "_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName": typeof _model_tournamentRegistrations__helpers_sortTournamentCompetitorsByName; "_model/tournamentRegistrations/index": typeof _model_tournamentRegistrations_index; "_model/tournamentRegistrations/mutations/createTournamentRegistration": typeof _model_tournamentRegistrations_mutations_createTournamentRegistration; "_model/tournamentRegistrations/mutations/deleteTournamentRegistration": typeof _model_tournamentRegistrations_mutations_deleteTournamentRegistration; @@ -572,6 +576,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/_helpers/extractSearchTokens": typeof _model_tournaments__helpers_extractSearchTokens; "_model/tournaments/_helpers/getAvailableActions": typeof _model_tournaments__helpers_getAvailableActions; "_model/tournaments/_helpers/getDisplayName": typeof _model_tournaments__helpers_getDisplayName; + "_model/tournaments/_helpers/getLastVisibleRound": typeof _model_tournaments__helpers_getLastVisibleRound; "_model/tournaments/_helpers/getTournamentDeep": typeof _model_tournaments__helpers_getTournamentDeep; "_model/tournaments/_helpers/getTournamentNextRound": typeof _model_tournaments__helpers_getTournamentNextRound; "_model/tournaments/_helpers/getTournamentPlayerUserIds": typeof _model_tournaments__helpers_getTournamentPlayerUserIds; @@ -588,6 +593,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/mutations/toggleTournamentAlignmentsRevealed": typeof _model_tournaments_mutations_toggleTournamentAlignmentsRevealed; "_model/tournaments/mutations/toggleTournamentListsRevealed": typeof _model_tournaments_mutations_toggleTournamentListsRevealed; "_model/tournaments/mutations/updateTournament": typeof _model_tournaments_mutations_updateTournament; + "_model/tournaments/mutations/updateTournamentPairingConfig": typeof _model_tournaments_mutations_updateTournamentPairingConfig; "_model/tournaments/queries/getTournament": typeof _model_tournaments_queries_getTournament; "_model/tournaments/queries/getTournamentByTournamentPairing": typeof _model_tournaments_queries_getTournamentByTournamentPairing; "_model/tournaments/queries/getTournamentOpenRound": typeof _model_tournaments_queries_getTournamentOpenRound; diff --git a/convex/_model/common/tournamentPairingConfig.ts b/convex/_model/common/tournamentPairingConfig.ts index 20730f1d..d1ee1451 100644 --- a/convex/_model/common/tournamentPairingConfig.ts +++ b/convex/_model/common/tournamentPairingConfig.ts @@ -1,4 +1,36 @@ -import { tournamentPairingConfig as config } from '@ianpaschal/combat-command-game-systems/common'; +import { TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; +import { createEnumSchema } from '@ianpaschal/combat-command-game-systems/utils'; import { zodToConvex } from 'convex-helpers/server/zod'; +import { z } from 'zod'; -export const tournamentPairingConfig = zodToConvex(config.schema); +import { Id } from '../../_generated/dataModel'; + +export const schema = z.object({ + orderBy: z.union([z.literal('ranking'), z.literal('random')]), + policies: z.object({ + sameAlignment: createEnumSchema(TournamentPairingPolicy), + repeat: createEnumSchema(TournamentPairingPolicy), + }), + tableCount: z.optional(z.number().min(1)), // TODO: REMOVE POST MIGRATION +}); + +export const generationSchema = schema.extend({ + include: z.array(z.string()).transform((val): Id<'tournamentCompetitors'>[] => val as Id<'tournamentCompetitors'>[]), +}); + +export type TournamentPairingConfig = z.infer; + +export type TournamentPairingPolicies = Partial; + +export const defaultValues = { + orderBy: 'ranking', + policies: { + sameAlignment: TournamentPairingPolicy.Allow, + repeat: TournamentPairingPolicy.Block, + }, + tableCount: 1, +} satisfies TournamentPairingConfig; + +export const tournamentPairingConfig = zodToConvex(schema); + +export const tournamentPairingInput = zodToConvex(generationSchema); diff --git a/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts index fcb7ff4c..eea5e72e 100644 --- a/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts @@ -36,7 +36,7 @@ export const createTournamentCompetitor = async ( throw new ConvexError(getErrorMessage('CANNOT_MODIFY_ARCHIVED_TOURNAMENT')); } const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); const existingTeamNames = tournamentCompetitors.map((item) => item.teamName); if (args.teamName && existingTeamNames.includes(args.teamName)) { diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index 2a44b051..c9041b9d 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -16,10 +16,10 @@ export const getTournamentCompetitorsByTournament = async ( ctx: QueryCtx, args: Infer, ): Promise => { - const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + const results = await ctx.db.query('tournamentCompetitors') + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - return (await Promise.all(tournamentCompetitors.map(async (item) => ( + return (await Promise.all(results.map(async (item) => ( await deepenTournamentCompetitor(ctx, item, args.rankingRound) )))).sort(sortTournamentCompetitorsByName); }; diff --git a/convex/_model/tournamentCompetitors/table.ts b/convex/_model/tournamentCompetitors/table.ts index 6b015cd4..3a212142 100644 --- a/convex/_model/tournamentCompetitors/table.ts +++ b/convex/_model/tournamentCompetitors/table.ts @@ -22,4 +22,4 @@ export default defineTable({ ...editableFields, ...computedFields, }) - .index('by_tournament_id', ['tournamentId']); + .index('by_tournament', ['tournamentId']); diff --git a/convex/_model/tournamentPairings/_helpers/assignTables.ts b/convex/_model/tournamentPairings/_helpers/assignTables.ts index 5fd2628f..566947ce 100644 --- a/convex/_model/tournamentPairings/_helpers/assignTables.ts +++ b/convex/_model/tournamentPairings/_helpers/assignTables.ts @@ -20,7 +20,7 @@ export const assignTables = ( ); playedTablesMap.set(null, []); - const tableCount = (data.tournament?.competitorCount ?? 2) / 2; // TODO: Use actual table count + const tableCount = data.tournament.pairingConfig.tableCount!; // TODO remove assertion post MIGRATION const draftPairings = pairings.map((p) => ({ ...p, diff --git a/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts b/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts index ef1eca59..1dd0dd56 100644 --- a/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts +++ b/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts @@ -1,6 +1,7 @@ import { Infer, v } from 'convex/values'; import { api } from '../../../_generated/api'; +import { Id } from '../../../_generated/dataModel'; import { ActionCtx } from '../../../_generated/server'; import { tournamentPairingConfig } from '../../common/tournamentPairingConfig'; import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; @@ -17,11 +18,14 @@ export const generateDraftTournamentPairingsArgs = v.object({ tournamentId: v.id('tournaments'), round: v.number(), config: tournamentPairingConfig, + include: v.array(v.id('tournamentCompetitors')), }); +export type GenerateDraftTournamentPairingsArgs = Infer; + export const generateDraftTournamentPairings = async ( ctx: ActionCtx, - args: Infer, + args: GenerateDraftTournamentPairingsArgs, ): Promise => { const tournamentCompetitors = await ctx.runQuery( @@ -30,7 +34,9 @@ export const generateDraftTournamentPairings = async ( }, ); - const activeCompetitors = tournamentCompetitors.filter(({ active }) => active); + const include = new Set>(args.include); + + const activeCompetitors = tournamentCompetitors.filter((c) => include.has(c._id)); const orderedCompetitors: DeepTournamentCompetitor[] = []; if (args.config.orderBy === 'ranking') { diff --git a/convex/_model/tournamentPairings/index.ts b/convex/_model/tournamentPairings/index.ts index c48dddca..8234915e 100644 --- a/convex/_model/tournamentPairings/index.ts +++ b/convex/_model/tournamentPairings/index.ts @@ -8,7 +8,9 @@ export { deepenTournamentPairing, type TournamentPairingDeep, } from './_helpers/deepenTournamentPairing'; -export { generateDraftPairings } from './_helpers/generateDraftPairings'; +export { + generateDraftPairings, +} from './_helpers/generateDraftPairings'; export { TournamentPairingActionKey } from './_helpers/getAvailableActions'; export { getTournamentPairingDeep } from './_helpers/getTournamentPairingDeep'; export { getTournamentPairingShallow } from './_helpers/getTournamentPairingShallow'; @@ -24,6 +26,7 @@ export type { // Actions export { generateDraftTournamentPairings, + type GenerateDraftTournamentPairingsArgs, generateDraftTournamentPairingsArgs, } from './actions/generateDraftTournamentPairings'; export { diff --git a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts index edab209d..d7ac4292 100644 --- a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts +++ b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts @@ -77,9 +77,6 @@ export const createTournamentPairings = async ( if (!competitor) { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_MISSING_COMPETITOR')); } - if (!competitor.active) { - throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_INACTIVE_COMPETITOR')); - } if (pairedCompetitorIds.has(id)) { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_ALREADY_PAIRED_COMPETITOR')); } diff --git a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts index c2e3e1d5..e60aa56a 100644 --- a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts +++ b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts @@ -7,6 +7,7 @@ import { getDocStrict } from '../../common/_helpers/getDocStrict'; import { getErrorMessage } from '../../common/errors'; import { getListsByTournamentRegistration } from '../../lists'; import { checkUserIsTournamentOrganizer } from '../../tournamentOrganizers'; +import { getTournamentResultsByUser } from '../../tournamentResults'; import { getUser } from '../../users'; import { getAvailableActions } from './getAvailableActions'; @@ -14,6 +15,7 @@ import { getAvailableActions } from './getAvailableActions'; export const deepenTournamentRegistration = async ( ctx: QueryCtx, doc: Doc<'tournamentRegistrations'>, + round?: number, ) => { const userId = await getAuthUserId(ctx); const { details, ...restDoc } = doc; @@ -25,6 +27,11 @@ export const deepenTournamentRegistration = async ( const tournament = await getDocStrict(ctx, doc.tournamentId); + const results = await getTournamentResultsByUser(ctx, { + userId: doc.userId, + tournamentId: doc.tournamentId, + round: round ?? tournament?.lastRound ?? 0, + }); const availableActions = await getAvailableActions(ctx, doc); const isOrganizer = await checkUserIsTournamentOrganizer(ctx, tournament._id, userId); @@ -43,6 +50,7 @@ export const deepenTournamentRegistration = async ( return { ...restDoc, + ...results, availableActions, details: visibleDetails, user, diff --git a/convex/_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.ts b/convex/_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.ts new file mode 100644 index 00000000..97777b20 --- /dev/null +++ b/convex/_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.ts @@ -0,0 +1,6 @@ +import { DeepTournamentRegistration } from './deepenTournamentRegistration'; + +export const sortTournamentRegistrationsByName = ( + a: DeepTournamentRegistration, + b: DeepTournamentRegistration, +): number => a.displayName.localeCompare(b.displayName); diff --git a/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.ts b/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.ts index fe14488c..e4702ce8 100644 --- a/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.ts +++ b/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.ts @@ -5,19 +5,21 @@ import { deepenTournamentRegistration, DeepTournamentRegistration, } from '../_helpers/deepenTournamentRegistration'; +import { sortTournamentRegistrationsByName } from '../_helpers/sortTournamentCompetitorsByName'; export const getTournamentRegistrationsByTournamentArgs = v.object({ tournamentId: v.id('tournaments'), + rankingRound: v.optional(v.number()), }); export const getTournamentRegistrationsByTournament = async ( ctx: QueryCtx, args: Infer, ): Promise => { - const tournamentRegistrations = await ctx.db.query('tournamentRegistrations') + const results = await ctx.db.query('tournamentRegistrations') .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - return await Promise.all( - tournamentRegistrations.map(async (registration) => await deepenTournamentRegistration(ctx, registration)), - ); + return (await Promise.all(results.map(async (item) => ( + await deepenTournamentRegistration(ctx, item, args.rankingRound) + )))).sort(sortTournamentRegistrationsByName); }; diff --git a/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts b/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts index 03fab27d..6d1e99e3 100644 --- a/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts +++ b/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts @@ -28,7 +28,7 @@ export const aggregateTournamentData = async ( .withIndex('by_tournament', (q) => q.eq('tournamentId', tournament._id)) .collect(); const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', tournament._id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', tournament._id)) .collect(); const tournamentPairings = await ctx.db.query('tournamentPairings') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', tournament._id)) diff --git a/convex/_model/tournamentResults/mutations/refreshTournamentResult.ts b/convex/_model/tournamentResults/mutations/refreshTournamentResult.ts index 2e96e9a8..cb5275ff 100644 --- a/convex/_model/tournamentResults/mutations/refreshTournamentResult.ts +++ b/convex/_model/tournamentResults/mutations/refreshTournamentResult.ts @@ -29,7 +29,7 @@ export const refreshTournamentResult = async ( throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND')); } const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); const tournamentRegistrations = await ctx.db.query('tournamentRegistrations') .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) diff --git a/convex/_model/tournaments/_helpers/deepenTournament.ts b/convex/_model/tournaments/_helpers/deepenTournament.ts index 8f6e9ca6..af2d669b 100644 --- a/convex/_model/tournaments/_helpers/deepenTournament.ts +++ b/convex/_model/tournaments/_helpers/deepenTournament.ts @@ -9,6 +9,7 @@ import { } from '../../tournamentOrganizers'; import { getAvailableActions } from './getAvailableActions'; import { getDisplayName } from './getDisplayName'; +import { getLastVisibleRound } from './getLastVisibleRound'; import { getTournamentNextRound } from './getTournamentNextRound'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ @@ -24,43 +25,45 @@ import { getTournamentNextRound } from './getTournamentNextRound'; */ export const deepenTournament = async ( ctx: QueryCtx, - tournament: Doc<'tournaments'>, + doc: Doc<'tournaments'>, ) => { const userId = await getAuthUserId(ctx); - const logoUrl = await getStorageUrl(ctx, tournament.logoStorageId); - const bannerUrl = await getStorageUrl(ctx, tournament.bannerStorageId); - const availableActions = await getAvailableActions(ctx, tournament); + const logoUrl = await getStorageUrl(ctx, doc.logoStorageId); + const bannerUrl = await getStorageUrl(ctx, doc.bannerStorageId); + const availableActions = await getAvailableActions(ctx, doc); const tournamentOrganizers = await getTournamentOrganizersByTournament(ctx, { - tournamentId: tournament._id, + tournamentId: doc._id, }); - const isOrganizer = await checkUserIsTournamentOrganizer(ctx, tournament._id, userId); + const lastVisibleRound = await getLastVisibleRound(ctx, doc); + const isOrganizer = await checkUserIsTournamentOrganizer(ctx, doc._id, userId); const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', tournament._id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', doc._id)) .collect(); const tournamentRegistrations = await ctx.db.query('tournamentRegistrations') - .withIndex('by_tournament', (q) => q.eq('tournamentId', tournament._id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', doc._id)) .collect(); const playerUserIds = tournamentRegistrations.map((r) => r.userId); const activePlayerUserIds = tournamentRegistrations.filter((r) => r.active).map((p) => p.userId); return { - ...tournament, + ...doc, activePlayerCount: activePlayerUserIds.length, activePlayerUserIds, - alignmentsVisible: isOrganizer || tournament.alignmentsRevealed, + alignmentsVisible: isOrganizer || doc.alignmentsRevealed, availableActions, bannerUrl, competitorCount: tournamentCompetitors.length, - displayName: getDisplayName(tournament), - factionsVisible: isOrganizer || tournament.factionsRevealed, + displayName: getDisplayName(doc), + factionsVisible: isOrganizer || doc.factionsRevealed, + lastRound: lastVisibleRound, logoUrl, - maxPlayers : tournament.maxCompetitors * tournament.competitorSize, - nextRound: getTournamentNextRound(tournament), + maxPlayers : doc.maxCompetitors * doc.competitorSize, + nextRound: getTournamentNextRound(doc), organizers: tournamentOrganizers, playerCount: playerUserIds.length, playerUserIds, - useTeams: tournament.competitorSize > 1, + useTeams: doc.competitorSize > 1, }; }; diff --git a/convex/_model/tournaments/_helpers/getAvailableActions.ts b/convex/_model/tournaments/_helpers/getAvailableActions.ts index fc2dc300..140b6a1c 100644 --- a/convex/_model/tournaments/_helpers/getAvailableActions.ts +++ b/convex/_model/tournaments/_helpers/getAvailableActions.ts @@ -36,11 +36,6 @@ export enum TournamentActionKey { ToggleListsRevealed = 'toggleListsRevealed', - /** Set a published Tournament's status to 'active'. */ - Start = 'start', - - // TODO: UndoStart - /** Create TournamentPairings for a given round. */ ConfigureRound = 'configureRound', @@ -135,11 +130,7 @@ export const getAvailableActions = async ( actions.push(TournamentActionKey.ToggleListsRevealed); } - if (isOrganizer && doc.status === 'published') { // TODO: Check for at least 2 competitors - actions.push(TournamentActionKey.Start); - } - - if (isOrganizer && doc.status === 'active' && !hasCurrentRound && hasNextRound) { + if (isOrganizer && doc.status !== 'draft' && !hasCurrentRound && hasNextRound) { actions.push(TournamentActionKey.ConfigureRound); } diff --git a/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts b/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts new file mode 100644 index 00000000..c8f07f97 --- /dev/null +++ b/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts @@ -0,0 +1,141 @@ +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { convexTest } from 'convex-test'; +import { + describe, + expect, + it, +} from 'vitest'; + +import { createMockTournament } from '../../../_fixtures/createMockTournament'; +import schema from '../../../schema'; +import { getLastVisibleRound } from './getLastVisibleRound'; + +describe('getLastVisibleRound', () => { + it('returns last round when not final round.', async () => { + const t = convexTest(schema); + + const result = await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 1, + roundCount: 3, + status: 'active', + })); + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(1); + }); + + it('returns last round after the final round, when user is a TO.', async () => { + const t = convexTest(schema); + + const { userId, tournamentId } = await t.run(async (ctx) => { + const userId = await ctx.db.insert('users', { + email: 'to@test.com', + locationVisibility: 0, + nameVisibility: 0, + }); + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 2, + roundCount: 3, + status: 'active', + })); + await ctx.db.insert('tournamentOrganizers', { tournamentId, userId }); + return { userId, tournamentId }; + }); + + const result = await t.withIdentity({ subject: `${userId}|session` }).run(async (ctx) => { + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(2); + }); + + it('returns round before last after the final round, when user is not a TO.', async () => { + const t = convexTest(schema); + + const { userId, tournamentId } = await t.run(async (ctx) => { + const userId = await ctx.db.insert('users', { + email: 'user@test.com', + locationVisibility: 0, + nameVisibility: 0, + }); + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 2, + roundCount: 3, + status: 'active', + })); + return { userId, tournamentId }; + }); + + const result = await t.withIdentity({ subject: `${userId}|session` }).run(async (ctx) => { + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(1); + }); + + it('returns round before last after the final round, when user is not authenticated.', async () => { + const t = convexTest(schema); + + const result = await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 2, + roundCount: 3, + status: 'active', + })); + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(1); + }); + + it('does not return negative values on first round.', async () => { + const t = convexTest(schema); + + const result = await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 0, + roundCount: 1, + status: 'active', + })); + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(0); + }); + + it('returns undefined when lastRound is not set.', async () => { + const t = convexTest(schema); + + await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + roundCount: 3, + status: 'active', + })); + const doc = await ctx.db.get(tournamentId); + expect(await getLastVisibleRound(ctx, doc!)).toBeUndefined(); + }); + }); + + it('returns last round when tournament is archived.', async () => { + const t = convexTest(schema); + + const result = await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 2, + roundCount: 3, + status: 'archived', + })); + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(2); + }); +}); diff --git a/convex/_model/tournaments/_helpers/getLastVisibleRound.ts b/convex/_model/tournaments/_helpers/getLastVisibleRound.ts new file mode 100644 index 00000000..ad093024 --- /dev/null +++ b/convex/_model/tournaments/_helpers/getLastVisibleRound.ts @@ -0,0 +1,30 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; + +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { checkUserIsTournamentOrganizer } from '../../tournamentOrganizers'; + +/** + * Determines the last tournament round visible to the current user. + * Non-organizers cannot view final round rankings until the tournament is archived. + * @returns The last round which should be visible to the current user + */ +export const getLastVisibleRound = async ( + ctx: QueryCtx, + doc: Doc<'tournaments'>, +): Promise => { + const userId = await getAuthUserId(ctx); + const isOrganizer = await checkUserIsTournamentOrganizer(ctx, doc._id, userId); + + // Always show true last round to organizers or if the tournament has ended: + if (isOrganizer || doc.status === 'archived') { + return doc.lastRound; + } + + // Show penultimate round to players if last round has been completed: + if (doc.lastRound && doc.lastRound + 1 === doc.roundCount) { + return Math.max(doc.lastRound - 1, 0); + } + + return doc.lastRound; +}; diff --git a/convex/_model/tournaments/index.ts b/convex/_model/tournaments/index.ts index f5ed1b01..3f54696c 100644 --- a/convex/_model/tournaments/index.ts +++ b/convex/_model/tournaments/index.ts @@ -62,6 +62,10 @@ export { updateTournament, updateTournamentArgs, } from './mutations/updateTournament'; +export { + updateTournamentPairingConfig, + updateTournamentPairingConfigArgs, +} from './mutations/updateTournamentPairingConfig'; // Queries export { diff --git a/convex/_model/tournaments/mutations/deleteTournament.ts b/convex/_model/tournaments/mutations/deleteTournament.ts index b4a7c5e3..ccbba7ae 100644 --- a/convex/_model/tournaments/mutations/deleteTournament.ts +++ b/convex/_model/tournaments/mutations/deleteTournament.ts @@ -43,7 +43,7 @@ export const deleteTournament = async ( // Cascade to Tournament Competitors: const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.id)) .collect(); for (const record of tournamentCompetitors) { await ctx.db.delete(record._id); diff --git a/convex/_model/tournaments/mutations/updateTournamentPairingConfig.ts b/convex/_model/tournaments/mutations/updateTournamentPairingConfig.ts new file mode 100644 index 00000000..d2ea0c6b --- /dev/null +++ b/convex/_model/tournaments/mutations/updateTournamentPairingConfig.ts @@ -0,0 +1,23 @@ +import { Infer, v } from 'convex/values'; + +import { MutationCtx } from '../../../_generated/server'; +import { tournamentPairingConfig } from '../../common/tournamentPairingConfig'; +import { checkTournamentAuth } from '../_helpers/checkTournamentAuth'; +import { getTournamentShallow } from '../_helpers/getTournamentShallow'; + +export const updateTournamentPairingConfigArgs = v.object({ + tournamentId: v.id('tournaments'), + pairingConfig: tournamentPairingConfig, +}); + +export const updateTournamentPairingConfig = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + const tournament = await getTournamentShallow(ctx, args.tournamentId); + checkTournamentAuth(ctx, tournament); + await ctx.db.patch(args.tournamentId, { + pairingConfig: args.pairingConfig, + modifiedAt: Date.now(), + }); +}; diff --git a/convex/_model/utils/deleteTestTournament.ts b/convex/_model/utils/deleteTestTournament.ts index c503d384..cf81a9f9 100644 --- a/convex/_model/utils/deleteTestTournament.ts +++ b/convex/_model/utils/deleteTestTournament.ts @@ -16,7 +16,7 @@ export const deleteTestTournament = async ( // 2. Delete competitors const competitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', id)) .collect(); competitors.forEach(async ({ _id }) => { await ctx.db.delete(_id); diff --git a/convex/migrations.ts b/convex/migrations.ts index 0e19046a..9b2c6a0b 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -84,7 +84,7 @@ export const moveSoloCompetitorScoreAdjustments = migrations.define({ // Find all competitors for this tournament that have adjustments to migrate: const competitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', doc._id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', doc._id)) .collect(); let anyMoved = false; @@ -139,6 +139,21 @@ export const backfillListSubmittedAt = migrations.define({ }, }); +export const addTableCountToPairingConfig = migrations.define({ + table: 'tournaments', + migrateOne: async (ctx, doc) => { + if (doc.pairingConfig.tableCount) { + return; + } + await ctx.db.patch(doc._id, { + pairingConfig: { + ...doc.pairingConfig, + tableCount: Math.ceil(doc.maxCompetitors / 2), + }, + }); + }, +}); + export const convertPlayedAt = migrations.define({ table: 'matchResults', migrateOne: async (ctx, doc) => { diff --git a/convex/tournaments.ts b/convex/tournaments.ts index f9496111..0228306f 100644 --- a/convex/tournaments.ts +++ b/convex/tournaments.ts @@ -35,6 +35,11 @@ export const updateTournament = mutationWithTrigger({ handler: model.updateTournament, }); +export const updateTournamentPairingConfig = mutation({ + args: model.updateTournamentPairingConfigArgs, + handler: model.updateTournamentPairingConfig, +}); + export const deleteTournament = mutation({ args: model.deleteTournamentArgs, handler: model.deleteTournament, diff --git a/src/api.ts b/src/api.ts index c7b3e924..1c220fb2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,6 +11,12 @@ export { export { type Faction, } from '../convex/_model/common/faction'; +export { + type TournamentPairingConfig, + defaultValues as tournamentPairingConfigDefaultValues, + schema as tournamentPairingConfigSchema, + type TournamentPairingPolicies, +} from '../convex/_model/common/tournamentPairingConfig'; export type { RankingFactor, TournamentStatus, @@ -86,6 +92,7 @@ export { // Tournament Pairings export { type DraftTournamentPairing, + type GenerateDraftTournamentPairingsArgs, type ShallowTournamentPairing, type TournamentPairingDeep as TournamentPairing, TournamentPairingActionKey, diff --git a/src/components/FactionIndicator/FactionIndicator.tsx b/src/components/FactionIndicator/FactionIndicator.tsx index 4cb8e7ce..8f311ce3 100644 --- a/src/components/FactionIndicator/FactionIndicator.tsx +++ b/src/components/FactionIndicator/FactionIndicator.tsx @@ -1,30 +1,31 @@ import { getAlignmentDisplayName } from '@ianpaschal/combat-command-game-systems/common'; import clsx from 'clsx'; -import { Alignment, Faction } from '~/api'; +import { Alignment } from '~/api'; import { InfoPopover } from '~/components/generic/InfoPopover'; import { getAlignmentColor } from './FactionIndicator.utils'; import styles from './FactionIndicator.module.scss'; export interface FactionIndicatorProps { - alignments: Alignment[]; - factions: Faction[]; + alignment: Alignment | Alignment[] | null; + + // factions: Faction[]; className?: string; } export const FactionIndicator = ({ - alignments, + alignment, // factions, className, }: FactionIndicatorProps): JSX.Element => { // FIXME: Be smarter about showing competitors (teams) with multiple alignments - const primaryAlignment = alignments[0]; + const primaryAlignment = Array.isArray(alignment) ? alignment[0] : alignment; const color = getAlignmentColor(primaryAlignment); - const displayName = getAlignmentDisplayName(primaryAlignment) ?? 'Unknown Alignment'; + const displayName = primaryAlignment && getAlignmentDisplayName(primaryAlignment) || 'Unknown Alignment'; return ( diff --git a/src/components/FactionIndicator/FactionIndicator.utils.ts b/src/components/FactionIndicator/FactionIndicator.utils.ts index ac64e25a..d1a07334 100644 --- a/src/components/FactionIndicator/FactionIndicator.utils.ts +++ b/src/components/FactionIndicator/FactionIndicator.utils.ts @@ -4,8 +4,11 @@ import { Alignment as TeamYankeeV2 } from '@ianpaschal/combat-command-game-syste import { Alignment } from '~/api'; export const getAlignmentColor = ( - alignment?: Alignment, -): 'red' | 'blue' | 'mixed' => { + alignment?: Alignment | null, +): 'red' | 'blue' | 'gray' | 'mixed' => { + if (alignment === null) { + return 'gray'; + } if (alignment === FlamesOfWarV4.Allies) { return 'blue'; } diff --git a/src/components/FooterBar/FooterBar.tsx b/src/components/FooterBar/FooterBar.tsx index 99eb124d..9c6b5e77 100644 --- a/src/components/FooterBar/FooterBar.tsx +++ b/src/components/FooterBar/FooterBar.tsx @@ -1,4 +1,5 @@ import { ReactNode } from 'react'; +import { sx } from '@ianpaschal/combat-command-components'; import clsx from 'clsx'; import { MAX_WIDTH } from '~/settings'; @@ -16,7 +17,7 @@ export const FooterBar = ({ children, className, }: FooterBarProps): JSX.Element => ( -
+
{children}
diff --git a/src/components/PageWrapper/PageWrapper.module.scss b/src/components/PageWrapper/PageWrapper.module.scss index d48987e0..021c8286 100644 --- a/src/components/PageWrapper/PageWrapper.module.scss +++ b/src/components/PageWrapper/PageWrapper.module.scss @@ -1,5 +1,6 @@ @use "/src/style/variables"; @use "/src/style/flex"; +@use "/src/style/text"; @use "/src/style/backgrounds"; $header-height: 2.5rem; @@ -63,19 +64,22 @@ $header-height: 2.5rem; &_Header { display: grid; - grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem; + grid-template-columns: 1fr min-content 1fr; align-items: center; - justify-items: center; - min-height: $header-height; h1 { + @include text.single-line; + grid-column: 2; } - } - &_ContextMenu { - grid-column: 3; + &_Right { + display: flex; + grid-column: 3; + gap: 1rem; + justify-content: flex-end; + } } &_Body { diff --git a/src/components/PageWrapper/PageWrapper.tsx b/src/components/PageWrapper/PageWrapper.tsx index 8b3d3dc6..ef8bcbe7 100644 --- a/src/components/PageWrapper/PageWrapper.tsx +++ b/src/components/PageWrapper/PageWrapper.tsx @@ -1,9 +1,4 @@ -import { - cloneElement, - ReactElement, - ReactNode, - useEffect, -} from 'react'; +import { ReactNode, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { Button, ScrollArea } from '@ianpaschal/combat-command-components'; import clsx from 'clsx'; @@ -23,7 +18,7 @@ export interface PageWrapperProps { title?: string; hideTitle?: boolean; banner?: ReactNode; - contextMenu?: ReactElement; + actions?: ReactNode; } export const PageWrapper = ({ @@ -35,7 +30,7 @@ export const PageWrapper = ({ title, hideTitle = false, banner, - contextMenu, + actions, }: PageWrapperProps): JSX.Element => { const navigate = useNavigate(); const { pathname } = useLocation(); @@ -67,9 +62,9 @@ export const PageWrapper = ({ {title && (

{title}

)} - {contextMenu && cloneElement(contextMenu, { - className: clsx(contextMenu.props.className, styles.PageWrapper_ContextMenu), - })} +
+ {actions} +
)}
diff --git a/src/components/ScoreAdjustmentFields/ScoreAdjustmentFields.tsx b/src/components/ScoreAdjustmentFields/ScoreAdjustmentFields.tsx index 040c24d0..e1473656 100644 --- a/src/components/ScoreAdjustmentFields/ScoreAdjustmentFields.tsx +++ b/src/components/ScoreAdjustmentFields/ScoreAdjustmentFields.tsx @@ -39,7 +39,7 @@ export const ScoreAdjustmentFields = ({ const safeValue = result.success ? result.data : undefined; - const roundOptions = getRoundOptions(tournament.roundCount); + const roundOptions = getRoundOptions(tournament); const { getRankingFactorOptions } = getGameSystem(tournament.gameSystem); const factorOptions = getRankingFactorOptions().filter(({ value }) => ( diff --git a/src/components/TournamentCompetitorIdentity/TournamentCompetitorIdentity.module.scss b/src/components/TournamentCompetitorIdentity/TournamentCompetitorIdentity.module.scss index 9637dabe..631c94d9 100644 --- a/src/components/TournamentCompetitorIdentity/TournamentCompetitorIdentity.module.scss +++ b/src/components/TournamentCompetitorIdentity/TournamentCompetitorIdentity.module.scss @@ -1,10 +1,16 @@ @use "/src/style/flex"; .TournamentCompetitorIdentity { + // has an sx variant applied, and thus an invisible border: + --padding: 0.5rem; + --padding-end: 1rem; + @include flex.row($gap: 0.75rem); - margin: -0.5rem; - padding: 0.5rem; + margin: calc(-1 * var(--padding)); + margin-right: calc(-1 * var(--padding-end)); + padding: calc(var(--padding) - var(--border-width)); + padding-right: calc(var(--padding-end) - var(--border-width)); &[data-clickable="false"] { pointer-events: none; diff --git a/src/components/TournamentCompetitorIdentity/TournamentCompetitorIdentity.tsx b/src/components/TournamentCompetitorIdentity/TournamentCompetitorIdentity.tsx index e88005af..37fddad4 100644 --- a/src/components/TournamentCompetitorIdentity/TournamentCompetitorIdentity.tsx +++ b/src/components/TournamentCompetitorIdentity/TournamentCompetitorIdentity.tsx @@ -1,21 +1,19 @@ -import { MouseEvent, ReactElement } from 'react'; +import { MouseEvent } from 'react'; import { getStyleClassNames } from '@ianpaschal/combat-command-components'; import clsx from 'clsx'; -import { Ghost, Users } from 'lucide-react'; import { TournamentCompetitor, TournamentCompetitorId } from '~/api'; import { Avatar } from '~/components/generic/Avatar'; import { TournamentCompetitorAvatar } from '~/components/IdentityBadge'; -import { useGetTournamentCompetitor } from '~/services/tournamentCompetitors'; import styles from './TournamentCompetitorIdentity.module.scss'; export interface TournamentCompetitorIdentityProps { className?: string; loading?: boolean; + disabled?: boolean; placeholder?: Pick; - tournamentCompetitor?: TournamentCompetitor | null; - tournamentCompetitorId?: TournamentCompetitorId; + subject?: TournamentCompetitor | null; onClick?: (id?: TournamentCompetitorId) => void; size?: 'small' | 'normal' | 'large'; } @@ -23,49 +21,19 @@ export interface TournamentCompetitorIdentityProps { export const TournamentCompetitorIdentity = ({ className, loading = false, + disabled = false, placeholder, - tournamentCompetitor, - tournamentCompetitorId, + subject, onClick, size = 'normal', }: TournamentCompetitorIdentityProps): JSX.Element => { - const fetch = !tournamentCompetitor && tournamentCompetitorId; - const { data, loading: dataLoading } = useGetTournamentCompetitor(fetch ? { - id: tournamentCompetitorId, - } : 'skip'); - - const showLoading = dataLoading || loading; - - const { displayName } = tournamentCompetitor ?? data ?? placeholder ?? { displayName: 'Unknown Competitor', details: { alignments: [], factions: [] } }; - - let avatar: ReactElement | null; - - if (tournamentCompetitor || data) { - avatar = ( - - ); - } else { - avatar = ( - : } - muted - /> - ); - } - - const showDisabled = !tournamentCompetitor && !data; - + const { displayName } = subject ?? placeholder ?? { displayName: 'Unknown Competitor', details: { alignments: [], factions: [] } }; const handleClick = (e: MouseEvent): void => { e.preventDefault(); // Prevent submit if in a form - onClick?.(tournamentCompetitor?._id ?? data?._id); + if (subject && onClick) { + onClick(subject._id); + } }; - return (