diff --git a/.gitignore b/.gitignore index e1dc8237..575b20b5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,12 +15,13 @@ dist-ssr # Editor directories and files !.vscode/extensions.json -.idea +.claude .DS_Store -*.suo -*.ntvs* +.env*.local +.idea +.vercel *.njsproj +*.ntvs* *.sln +*.suo *.sw? -.vercel -.env*.local diff --git a/convex/_fixtures/createMockTournamentCompetitor.ts b/convex/_fixtures/createMockTournamentCompetitor.ts index ff841ef0..81cd779b 100644 --- a/convex/_fixtures/createMockTournamentCompetitor.ts +++ b/convex/_fixtures/createMockTournamentCompetitor.ts @@ -23,6 +23,10 @@ export const createMockTournamentCompetitor = ( _id: overrides.id as Id<'tournamentCompetitors'>, activeRegistrationCount: 0, availableActions: [], + details: { + alignments: [], + factions: [], + }, }); export const createMockTournamentCompetitors = ( diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 88435b16..29fe0a58 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -27,8 +27,10 @@ import type * as _model_common__helpers_getStaticEnumConvexValidator from "../_m import type * as _model_common__helpers_getStorageUrl from "../_model/common/_helpers/getStorageUrl.js"; import type * as _model_common__helpers_intersectArrays from "../_model/common/_helpers/intersectArrays.js"; import type * as _model_common__helpers_notNullOrUndefined from "../_model/common/_helpers/notNullOrUndefined.js"; +import type * as _model_common_alignment from "../_model/common/alignment.js"; import type * as _model_common_baseStats from "../_model/common/baseStats.js"; import type * as _model_common_errors from "../_model/common/errors.js"; +import type * as _model_common_faction from "../_model/common/faction.js"; import type * as _model_common_gameSystemConfig from "../_model/common/gameSystemConfig.js"; import type * as _model_common_leagueStatus from "../_model/common/leagueStatus.js"; import type * as _model_common_location from "../_model/common/location.js"; @@ -121,6 +123,7 @@ import type * as _model_photos_queries_getPhoto from "../_model/photos/queries/g import type * as _model_photos_table from "../_model/photos/table.js"; import type * as _model_tournamentCompetitors__helpers_deepenTournamentCompetitor from "../_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.js"; import type * as _model_tournamentCompetitors__helpers_getAvailableActions from "../_model/tournamentCompetitors/_helpers/getAvailableActions.js"; +import type * as _model_tournamentCompetitors__helpers_getDetails from "../_model/tournamentCompetitors/_helpers/getDetails.js"; import type * as _model_tournamentCompetitors__helpers_getDisplayName from "../_model/tournamentCompetitors/_helpers/getDisplayName.js"; import type * as _model_tournamentCompetitors__helpers_sortTournamentCompetitorsByName from "../_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.js"; import type * as _model_tournamentCompetitors_index from "../_model/tournamentCompetitors/index.js"; @@ -169,7 +172,7 @@ import type * as _model_tournamentRegistrations__helpers_getDeleteSuccessMessage 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"; -import type * as _model_tournamentRegistrations_mutations_toggleActive from "../_model/tournamentRegistrations/mutations/toggleActive.js"; +import type * as _model_tournamentRegistrations_mutations_toggleTournamentRegistrationActive from "../_model/tournamentRegistrations/mutations/toggleTournamentRegistrationActive.js"; import type * as _model_tournamentRegistrations_queries_getTournamentRegistrationByTournamentUser from "../_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.js"; import type * as _model_tournamentRegistrations_queries_getTournamentRegistrationsByCompetitor from "../_model/tournamentRegistrations/queries/getTournamentRegistrationsByCompetitor.js"; import type * as _model_tournamentRegistrations_queries_getTournamentRegistrationsByTournament from "../_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.js"; @@ -218,6 +221,7 @@ import type * as _model_tournaments_mutations_endTournamentRound from "../_model import type * as _model_tournaments_mutations_publishTournament from "../_model/tournaments/mutations/publishTournament.js"; import type * as _model_tournaments_mutations_startTournament from "../_model/tournaments/mutations/startTournament.js"; import type * as _model_tournaments_mutations_startTournamentRound from "../_model/tournaments/mutations/startTournamentRound.js"; +import type * as _model_tournaments_mutations_toggleTournamentAlignmentsRevealed from "../_model/tournaments/mutations/toggleTournamentAlignmentsRevealed.js"; import type * as _model_tournaments_mutations_updateTournament from "../_model/tournaments/mutations/updateTournament.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"; @@ -328,8 +332,10 @@ declare const fullApi: ApiFromModules<{ "_model/common/_helpers/getStorageUrl": typeof _model_common__helpers_getStorageUrl; "_model/common/_helpers/intersectArrays": typeof _model_common__helpers_intersectArrays; "_model/common/_helpers/notNullOrUndefined": typeof _model_common__helpers_notNullOrUndefined; + "_model/common/alignment": typeof _model_common_alignment; "_model/common/baseStats": typeof _model_common_baseStats; "_model/common/errors": typeof _model_common_errors; + "_model/common/faction": typeof _model_common_faction; "_model/common/gameSystemConfig": typeof _model_common_gameSystemConfig; "_model/common/leagueStatus": typeof _model_common_leagueStatus; "_model/common/location": typeof _model_common_location; @@ -422,6 +428,7 @@ declare const fullApi: ApiFromModules<{ "_model/photos/table": typeof _model_photos_table; "_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor": typeof _model_tournamentCompetitors__helpers_deepenTournamentCompetitor; "_model/tournamentCompetitors/_helpers/getAvailableActions": typeof _model_tournamentCompetitors__helpers_getAvailableActions; + "_model/tournamentCompetitors/_helpers/getDetails": typeof _model_tournamentCompetitors__helpers_getDetails; "_model/tournamentCompetitors/_helpers/getDisplayName": typeof _model_tournamentCompetitors__helpers_getDisplayName; "_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName": typeof _model_tournamentCompetitors__helpers_sortTournamentCompetitorsByName; "_model/tournamentCompetitors/index": typeof _model_tournamentCompetitors_index; @@ -470,7 +477,7 @@ declare const fullApi: ApiFromModules<{ "_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; - "_model/tournamentRegistrations/mutations/toggleActive": typeof _model_tournamentRegistrations_mutations_toggleActive; + "_model/tournamentRegistrations/mutations/toggleTournamentRegistrationActive": typeof _model_tournamentRegistrations_mutations_toggleTournamentRegistrationActive; "_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser": typeof _model_tournamentRegistrations_queries_getTournamentRegistrationByTournamentUser; "_model/tournamentRegistrations/queries/getTournamentRegistrationsByCompetitor": typeof _model_tournamentRegistrations_queries_getTournamentRegistrationsByCompetitor; "_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament": typeof _model_tournamentRegistrations_queries_getTournamentRegistrationsByTournament; @@ -519,6 +526,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/mutations/publishTournament": typeof _model_tournaments_mutations_publishTournament; "_model/tournaments/mutations/startTournament": typeof _model_tournaments_mutations_startTournament; "_model/tournaments/mutations/startTournamentRound": typeof _model_tournaments_mutations_startTournamentRound; + "_model/tournaments/mutations/toggleTournamentAlignmentsRevealed": typeof _model_tournaments_mutations_toggleTournamentAlignmentsRevealed; "_model/tournaments/mutations/updateTournament": typeof _model_tournaments_mutations_updateTournament; "_model/tournaments/queries/getTournament": typeof _model_tournaments_queries_getTournament; "_model/tournaments/queries/getTournamentByTournamentPairing": typeof _model_tournaments_queries_getTournamentByTournamentPairing; diff --git a/convex/_model/common/alignment.ts b/convex/_model/common/alignment.ts new file mode 100644 index 00000000..7abc93eb --- /dev/null +++ b/convex/_model/common/alignment.ts @@ -0,0 +1,14 @@ +import { Alignment as flamesOfWarV4 } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; +import { Alignment as teamYankeeV2 } from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; +import { v } from 'convex/values'; + +export type Alignment = `${flamesOfWarV4}` | `${teamYankeeV2}`; + +const values: Alignment[] = [ + ...Object.values(flamesOfWarV4), + ...Object.values(teamYankeeV2), +]; + +export const alignment = v.union( + ...values.map((item) => v.literal(item)), +); diff --git a/convex/_model/common/faction.ts b/convex/_model/common/faction.ts new file mode 100644 index 00000000..832efff1 --- /dev/null +++ b/convex/_model/common/faction.ts @@ -0,0 +1,14 @@ +import { Faction as flamesOfWarV4 } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; +import { Faction as teamYankeeV2 } from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; +import { v } from 'convex/values'; + +export type Faction = `${flamesOfWarV4}` | `${teamYankeeV2}`; + +const values: Faction[] = [ + ...Object.values(flamesOfWarV4), + ...Object.values(teamYankeeV2), +]; + +export const faction = v.union( + ...values.map((item) => v.literal(item)), +); diff --git a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts index 025a5e05..e41c11c6 100644 --- a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts @@ -3,6 +3,7 @@ import { QueryCtx } from '../../../_generated/server'; import { getTournamentRegistrationsByCompetitor } from '../../tournamentRegistrations'; import { getTournamentResultsByCompetitor } from '../../tournamentResults'; import { getAvailableActions } from './getAvailableActions'; +import { getDetails } from './getDetails'; import { getDisplayName } from './getDisplayName'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ @@ -33,6 +34,7 @@ export const deepenTournamentCompetitor = async ( }); const availableActions = await getAvailableActions(ctx, doc); const displayName = await getDisplayName(ctx, doc); + const details = getDetails(registrations); return { ...doc, ...results, @@ -40,6 +42,7 @@ export const deepenTournamentCompetitor = async ( availableActions, displayName, registrations, + details, }; }; diff --git a/convex/_model/tournamentCompetitors/_helpers/getDetails.ts b/convex/_model/tournamentCompetitors/_helpers/getDetails.ts new file mode 100644 index 00000000..410c5b4f --- /dev/null +++ b/convex/_model/tournamentCompetitors/_helpers/getDetails.ts @@ -0,0 +1,28 @@ +import { Alignment } from '../../common/alignment'; +import { Faction } from '../../common/faction'; +import { DeepTournamentRegistration } from '../../tournamentRegistrations/_helpers/deepenTournamentRegistration'; + +export const getDetails = ( + registrations: DeepTournamentRegistration[], +): { + alignments: Alignment[]; + factions: Faction[]; +} => { + + const alignments = new Set(); + const factions = new Set(); + + for (const reg of registrations) { + for (const a of reg.alignments) { + alignments.add(a); + } + for (const f of reg.factions) { + factions.add(f); + } + } + + return { + alignments: Array.from(alignments), + factions: Array.from(factions), + }; +}; diff --git a/convex/_model/tournamentCompetitors/_helpers/getDisplayName.ts b/convex/_model/tournamentCompetitors/_helpers/getDisplayName.ts index 3bc78174..b79e975b 100644 --- a/convex/_model/tournamentCompetitors/_helpers/getDisplayName.ts +++ b/convex/_model/tournamentCompetitors/_helpers/getDisplayName.ts @@ -27,7 +27,7 @@ export const getDisplayName = async ( } // If competitor has only 1 player, just use the player's name: - if (tournament?.competitorSize === 1 && activeRegistrations[0].user) { + if (tournament.competitorSize === 1 && activeRegistrations[0]?.user) { return activeRegistrations[0].user.displayName; } diff --git a/convex/_model/tournamentCompetitors/mutations/toggleTournamentCompetitorActive.ts b/convex/_model/tournamentCompetitors/mutations/toggleTournamentCompetitorActive.ts index 0b9aad34..637c9621 100644 --- a/convex/_model/tournamentCompetitors/mutations/toggleTournamentCompetitorActive.ts +++ b/convex/_model/tournamentCompetitors/mutations/toggleTournamentCompetitorActive.ts @@ -16,7 +16,7 @@ export const toggleTournamentCompetitorActiveArgs = v.object({ export const toggleTournamentCompetitorActive = async ( ctx: MutationCtx, args: Infer, -): Promise => { +): Promise => { // --- CHECK AUTH ---- const userId = await checkAuth(ctx); @@ -48,7 +48,7 @@ export const toggleTournamentCompetitorActive = async ( } // ---- PRIMARY ACTIONS ---- - await ctx.db.patch(args.id, { - active: !tournamentCompetitor.active, - }); + const active = !tournamentCompetitor.active; + await ctx.db.patch(args.id, { active }); + return active; }; diff --git a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.test.ts b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.test.ts index bddf275e..eb9707f4 100644 --- a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.test.ts +++ b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.test.ts @@ -82,7 +82,7 @@ describe('generateDraftPairings', () => { it('Does allow repeat pairings when explicitly enabled.', () => { // ---- Act ---- - const pairings = generateDraftPairings(competitors, true); + const pairings = generateDraftPairings(competitors, { allowRepeats: true }); // ---- Assert ---- expect(pairings.length).toBe(2); diff --git a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts index 44428698..e22d38d9 100644 --- a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts +++ b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts @@ -9,6 +9,11 @@ import { assignBye } from './assignBye'; */ export type CompetitorPair = [DeepTournamentCompetitor, DeepTournamentCompetitor | null]; +export type PairingOptions = Partial<{ + allowRepeats: boolean; + allowSameAlignment: boolean; +}>; + /** * Generates draft pairings for an array of ranked TournamentCompetitors. * @@ -22,7 +27,10 @@ export type CompetitorPair = [DeepTournamentCompetitor, DeepTournamentCompetitor */ export const generateDraftPairings = ( orderedCompetitors: DeepTournamentCompetitor[], - allowRepeats: boolean = false, + options: PairingOptions = { + allowRepeats: false, + allowSameAlignment: true, + }, ): CompetitorPair[] => { const pairings: CompetitorPair[] = []; @@ -33,14 +41,9 @@ export const generateDraftPairings = ( } // Resolve pairings by input order: - const resolvedPairings = recursivePair(restCompetitors, allowRepeats); + const resolvedPairings = recursivePair(restCompetitors, options); if (resolvedPairings === null) { - if (allowRepeats) { - // TODO: Figure out if this is needed... it should be impossible! - // ...but good to know if we ever see it, that it is, indeed, possible... - throw new ConvexError(getErrorMessage('NO_VALID_PAIRINGS_POSSIBLE')); - } - throw new ConvexError(getErrorMessage('NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_REPEAT')); + throw new ConvexError(getErrorMessage('NO_VALID_PAIRINGS_POSSIBLE')); } pairings.push(...resolvedPairings); return pairings; @@ -51,7 +54,7 @@ export const generateDraftPairings = ( */ export const recursivePair = ( pool: DeepTournamentCompetitor[], - allowRepeats: boolean, + options: PairingOptions, ): CompetitorPair[] | null => { if (pool.length === 0) { return []; // everyone paired @@ -59,15 +62,55 @@ export const recursivePair = ( const [ anchor, ...rest ] = pool; // best remaining for (let i = 0; i < rest.length; ++i) { const opponent = rest[i]; - const havePlayed = anchor.opponentIds.includes(opponent._id); - if (havePlayed && !allowRepeats) { + if (checkIfRepeat(anchor, opponent) && !options.allowRepeats) { + continue; // hard‑constraint + } + if (checkIfSameAlignment(anchor, opponent) && !options.allowSameAlignment) { continue; // hard‑constraint } const nextPool = rest.slice(0, i).concat(rest.slice(i + 1)); - const sub = recursivePair(nextPool, allowRepeats); + const sub = recursivePair(nextPool, options); if (sub) { return [ [ anchor, opponent ], ...sub ]; } // success – unwind } return null; // dead end – back‑track }; + +const checkIfRepeat = ( + a: DeepTournamentCompetitor, + b: DeepTournamentCompetitor, +): boolean => { + if (a.opponentIds.includes(b._id)) { + return true; + } + return false; +}; + +const checkIfSameAlignment = ( + a: DeepTournamentCompetitor, + b: DeepTournamentCompetitor, +): boolean => { + const aAlignments = a.details.alignments; + const bAlignments = b.details.alignments; + + // A and B must either: + + // have at least 1 with alignment 'flexible' + if (aAlignments.includes('flexible') || bAlignments.includes('flexible')) { + return false; + } + + // have at least 1 with multiple alignments + if (aAlignments.length > 1 || bAlignments.length > 1) { + return false; + } + + // have 1 each, but be different + if (aAlignments.length === 1 && bAlignments.length === 1 && aAlignments[0] !== bAlignments[0]) { + return false; + } + + // Otherwise, they have the same single alignment + return true; +}; diff --git a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts index 85e1198c..42e8af11 100644 --- a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts +++ b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts @@ -1,8 +1,10 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; import { ConvexError } from 'convex/values'; import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; import { getErrorMessage } from '../../common/errors'; +import { checkUserIsTournamentOrganizer } from '../../tournamentOrganizers'; import { getUser } from '../../users'; import { getAvailableActions } from './getAvailableActions'; @@ -11,17 +13,35 @@ export const deepenTournamentRegistration = async ( ctx: QueryCtx, doc: Doc<'tournamentRegistrations'>, ) => { + const userId = await getAuthUserId(ctx); + const { details, ...restDoc } = doc; + const user = await getUser(ctx, { id: doc.userId }); if (!user) { throw new ConvexError(getErrorMessage('USER_NOT_FOUND')); } + const tournament = await ctx.db.get(doc.tournamentId); + if (!tournament) { + throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND')); + } + const availableActions = await getAvailableActions(ctx, doc); + const isOrganizer = await checkUserIsTournamentOrganizer(ctx, tournament._id, userId); + const alignmentsVisible = isOrganizer || tournament.alignmentsRevealed; + const factionsVisible = isOrganizer || tournament.factionsRevealed; + + // TODO: Use lists if they are present. getDetails() + const alignments = Array.from(new Set(alignmentsVisible && details?.alignment ? [details.alignment] : [])); + const factions = Array.from(new Set(factionsVisible && details?.faction ? [details.faction] : [])); + return { - ...doc, + ...restDoc, availableActions, user, displayName: user.displayName, + alignments, + factions, }; }; diff --git a/convex/_model/tournamentRegistrations/index.ts b/convex/_model/tournamentRegistrations/index.ts index 0043da2c..28f4e885 100644 --- a/convex/_model/tournamentRegistrations/index.ts +++ b/convex/_model/tournamentRegistrations/index.ts @@ -24,7 +24,7 @@ export { export { toggleTournamentRegistrationActive, toggleTournamentRegistrationActiveArgs, -} from './mutations/toggleActive'; +} from './mutations/toggleTournamentRegistrationActive'; // Queries export { diff --git a/convex/_model/tournamentRegistrations/mutations/createTournamentRegistration.ts b/convex/_model/tournamentRegistrations/mutations/createTournamentRegistration.ts index 7475ecc3..c60f2cee 100644 --- a/convex/_model/tournamentRegistrations/mutations/createTournamentRegistration.ts +++ b/convex/_model/tournamentRegistrations/mutations/createTournamentRegistration.ts @@ -12,10 +12,13 @@ import { VisibilityLevel } from '../../common/VisibilityLevel'; import { getTournamentOrganizersByTournament } from '../../tournamentOrganizers'; import { checkUserIsRegistered } from '../_helpers/checkUserIsRegistered'; import { getCreateSuccessMessage } from '../_helpers/getCreateSuccessMessage'; +import { editableFields } from '../table'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const { tournamentCompetitorId, ...restEditableFields } = editableFields; export const createTournamentRegistrationArgs = v.object({ - userId: v.id('users'), - tournamentId: v.id('tournaments'), + ...restEditableFields, tournamentCompetitorId: v.optional(v.id('tournamentCompetitors')), tournamentCompetitor: v.optional(v.object({ teamName: v.optional(v.string()), @@ -98,6 +101,7 @@ export const createTournamentRegistration = async ( tournamentCompetitorId, tournamentId: args.tournamentId, userId: args.userId, + details: args.details, }); // Update user's name visibility if consent given: diff --git a/convex/_model/tournamentRegistrations/mutations/toggleActive.ts b/convex/_model/tournamentRegistrations/mutations/toggleTournamentRegistrationActive.ts similarity index 100% rename from convex/_model/tournamentRegistrations/mutations/toggleActive.ts rename to convex/_model/tournamentRegistrations/mutations/toggleTournamentRegistrationActive.ts diff --git a/convex/_model/tournamentRegistrations/table.ts b/convex/_model/tournamentRegistrations/table.ts index 74451cef..9ed99387 100644 --- a/convex/_model/tournamentRegistrations/table.ts +++ b/convex/_model/tournamentRegistrations/table.ts @@ -1,10 +1,17 @@ import { defineTable } from 'convex/server'; import { v } from 'convex/values'; +import { alignment } from '../common/alignment'; +import { faction } from '../common/faction'; + export const editableFields = { userId: v.id('users'), tournamentId: v.id('tournaments'), tournamentCompetitorId: v.id('tournamentCompetitors'), + details: v.optional(v.object({ + alignment: v.optional(alignment), + faction: v.optional(faction), + })), }; /** diff --git a/convex/_model/tournaments/_helpers/deepenTournament.ts b/convex/_model/tournaments/_helpers/deepenTournament.ts index 800ca17e..1e36e20c 100644 --- a/convex/_model/tournaments/_helpers/deepenTournament.ts +++ b/convex/_model/tournaments/_helpers/deepenTournament.ts @@ -1,7 +1,9 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; + import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; import { getStorageUrl } from '../../common/_helpers/getStorageUrl'; -import { getTournamentOrganizersByTournament } from '../../tournamentOrganizers'; +import { checkUserIsTournamentOrganizer, getTournamentOrganizersByTournament } from '../../tournamentOrganizers'; import { getAvailableActions } from './getAvailableActions'; import { getDisplayName } from './getDisplayName'; import { getTournamentNextRound } from './getTournamentNextRound'; @@ -21,12 +23,14 @@ export const deepenTournament = async ( ctx: QueryCtx, tournament: 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 tournamentOrganizers = await getTournamentOrganizersByTournament(ctx, { tournamentId: tournament._id, }); + const isOrganizer = await checkUserIsTournamentOrganizer(ctx, tournament._id, userId); const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', tournament._id)) .collect(); @@ -41,10 +45,12 @@ export const deepenTournament = async ( ...tournament, activePlayerCount: activePlayerUserIds.length, activePlayerUserIds, + alignmentsVisible: isOrganizer || tournament.alignmentsRevealed, availableActions, bannerUrl, competitorCount: tournamentCompetitors.length, displayName: getDisplayName(tournament), + factionsVisible: isOrganizer || tournament.factionsRevealed, logoUrl, maxPlayers : tournament.maxCompetitors * tournament.competitorSize, nextRound: getTournamentNextRound(tournament), diff --git a/convex/_model/tournaments/_helpers/getAvailableActions.ts b/convex/_model/tournaments/_helpers/getAvailableActions.ts index 3c4cafaf..df995202 100644 --- a/convex/_model/tournaments/_helpers/getAvailableActions.ts +++ b/convex/_model/tournaments/_helpers/getAvailableActions.ts @@ -29,6 +29,10 @@ export enum TournamentActionKey { // TODO: UndoCancel + ToggleAlignmentsRevealed = 'toggleAlignmentsRevealed', + + ToggleFactionsRevealed = 'toggleFactionsRevealed', + /** Set a published Tournament's status to 'active'. */ Start = 'start', @@ -119,6 +123,11 @@ export const getAvailableActions = async ( if (isOrganizer && doc.status !== 'draft' && !hasCurrentRound) { actions.push(TournamentActionKey.AddPlayer); } + + if (isOrganizer) { + actions.push(TournamentActionKey.ToggleAlignmentsRevealed); + actions.push(TournamentActionKey.ToggleFactionsRevealed); + } if (isOrganizer && doc.status === 'published') { // TODO: Check for at least 2 competitors actions.push(TournamentActionKey.Start); diff --git a/convex/_model/tournaments/index.ts b/convex/_model/tournaments/index.ts index 679dc8a2..13fb519a 100644 --- a/convex/_model/tournaments/index.ts +++ b/convex/_model/tournaments/index.ts @@ -49,6 +49,10 @@ export { startTournamentRound, startTournamentRoundArgs, } from './mutations/startTournamentRound'; +export { + toggleTournamentAlignmentsRevealed, + toggleTournamentAlignmentsRevealedArgs, +} from './mutations/toggleTournamentAlignmentsRevealed'; export { updateTournament, updateTournamentArgs, diff --git a/convex/_model/tournaments/mutations/toggleTournamentAlignmentsRevealed.ts b/convex/_model/tournaments/mutations/toggleTournamentAlignmentsRevealed.ts new file mode 100644 index 00000000..046bca95 --- /dev/null +++ b/convex/_model/tournaments/mutations/toggleTournamentAlignmentsRevealed.ts @@ -0,0 +1,47 @@ +import { + ConvexError, + Infer, + v, +} from 'convex/values'; + +import { MutationCtx } from '../../../_generated/server'; +import { checkAuth } from '../../common/_helpers/checkAuth'; +import { getErrorMessage } from '../../common/errors'; +import { checkUserIsTournamentOrganizer } from '../../tournamentOrganizers'; + +export const toggleTournamentAlignmentsRevealedArgs = v.object({ + id: v.id('tournaments'), +}); + +export const toggleTournamentAlignmentsRevealed = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + // --- CHECK AUTH ---- + const userId = await checkAuth(ctx); + + // ---- VALIDATE ---- + const tournament = await ctx.db.get(args.id); + if (!tournament) { + throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND')); + } + + // ---- EXTENDED AUTH CHECK ---- + /* These user IDs can make changes to this tournament competitor: + * - Tournament organizers; + */ + const isOrganizer = await checkUserIsTournamentOrganizer(ctx, args.id, userId); + if (!isOrganizer) { + throw new ConvexError(getErrorMessage('USER_DOES_NOT_HAVE_PERMISSION')); + } + + // ---- PRIMARY ACTIONS ---- + const alignmentsRevealed = !tournament.alignmentsRevealed; + await ctx.db.patch(args.id, { + alignmentsRevealed, + ...(alignmentsRevealed === false && { + factionsRevealed: false, + }), + }); + return alignmentsRevealed; +}; diff --git a/convex/_model/tournaments/table.ts b/convex/_model/tournaments/table.ts index a0a6af0e..944d8d3d 100644 --- a/convex/_model/tournaments/table.ts +++ b/convex/_model/tournaments/table.ts @@ -36,7 +36,6 @@ export const editableFields = { registrationClosesAt: v.number(), listSubmissionClosesAt: v.number(), - requireRealNames: v.boolean(), rulesPackUrl: v.optional(v.string()), editionYear: v.optional(v.number()), @@ -56,6 +55,16 @@ export const editableFields = { })), useNationalTeams: v.boolean(), + // Registrations + requireRealNames: v.boolean(), + registrationDetails: v.optional(v.object({ + alignment: v.union(v.literal('optional'), v.literal('required'), v.null()), + faction: v.union(v.literal('optional'), v.literal('required'), v.null()), + })), + + alignmentsRevealed: v.optional(v.boolean()), + factionsRevealed: v.optional(v.boolean()), + // Format pairingMethod: tournamentPairingMethod, roundCount: v.number(), diff --git a/convex/tournaments.ts b/convex/tournaments.ts index 2a216bf7..b61b8866 100644 --- a/convex/tournaments.ts +++ b/convex/tournaments.ts @@ -40,6 +40,11 @@ export const deleteTournament = mutation({ handler: model.deleteTournament, }); +export const toggleTournamentAlignmentsRevealed = mutation({ + args: model.toggleTournamentAlignmentsRevealedArgs, + handler: model.toggleTournamentAlignmentsRevealed, +}); + export const endTournamentRound = mutation({ args: model.endTournamentRoundArgs, handler: model.endTournamentRound, diff --git a/package-lock.json b/package-lock.json index 9685d16e..9cc5aa65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,8 @@ "@dnd-kit/sortable": "^10.0.0", "@fontsource/figtree": "^5.1.0", "@hookform/resolvers": "^3.9.0", - "@ianpaschal/combat-command-components": "^1.7.2", - "@ianpaschal/combat-command-game-systems": "^1.1.4", + "@ianpaschal/combat-command-components": "^1.8.0", + "@ianpaschal/combat-command-game-systems": "^1.2.0", "@mapbox/search-js-core": "^1.0.0-beta.25", "@radix-ui/colors": "^3.0.0", "@react-hook/window-size": "^3.1.1", @@ -191,6 +191,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -613,6 +614,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -636,6 +638,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -681,6 +684,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -758,6 +762,7 @@ "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@edge-runtime/primitives": "6.0.0" }, @@ -1495,9 +1500,9 @@ "license": "BSD-3-Clause" }, "node_modules/@ianpaschal/combat-command-components": { - "version": "1.7.2", - "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-components/1.7.2/aeec3af7a448d5ef0601bac5985c9486feee3a80", - "integrity": "sha512-LSXWRpddVdRNsjsqbUD/v43s/aNX2DnxDaCHi/37tjhb8qnlRBHLQhufjZmueupLV4/wBfYMa+tyiDa8KBJ2Og==", + "version": "1.8.0", + "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-components/1.8.0/ce5e99dbfc0babd642ba8f016d9a5bcf19b81707", + "integrity": "sha512-bVlbdRvfbrNbeKgR4guI9G5KY7aMKoxtZzXEX7SpDMwwe8aRQW4va8NcF/h6fN6Mp36so2IoDd+WLy00a0hbRA==", "license": "MIT", "dependencies": { "@base-ui/react": "^1.0.0", @@ -1553,9 +1558,9 @@ } }, "node_modules/@ianpaschal/combat-command-game-systems": { - "version": "1.1.4", - "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-game-systems/1.1.4/11238d3578a65d6683156780e08b25935cb84242", - "integrity": "sha512-dTh3lWnOF8YvpsWoLI30LIwkTpuQ/FjbJbOE/2qxmtYvQDrnPHfO7YTf7C7pe4RP4WQI+lAdpirRdoKtjAJI9w==", + "version": "1.2.0", + "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-game-systems/1.2.0/f7e4d868c759e727dc98f2190d24073d028f46be", + "integrity": "sha512-au90rG1BI95tmsq0Pfz0WRdfaJ8bk8A1FzGrfRbuQomXpQKb4QxOLLcqBBgHewpwgMj52TPzBM/FEye6A5WnmQ==", "license": "UNLICENSED", "dependencies": { "zod": "^3.25.76" @@ -2570,7 +2575,6 @@ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -4885,7 +4889,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@stylistic/eslint-plugin-js": { "version": "2.13.0", @@ -5157,8 +5162,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5316,6 +5320,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.14.tgz", "integrity": "sha512-BL1eyu/XWsFGTtDWOYULQEs4KR0qdtYfCxYAUYRoB7JP7h9ETYLgQTww6kH8Sj2C0pFGgrpM0XKv6/kbIzYJ1g==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5367,6 +5372,7 @@ "integrity": "sha512-gXLBtmlcRJeT09/sI4PxVwyrku6SaNUj/6cMubjE6T6XdY1fDmBL7r0nX0jbSZPU/Xr0KuwLLZh6aOYY5d91Xw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5378,6 +5384,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -5486,6 +5493,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -5964,6 +5972,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6437,6 +6446,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -6749,6 +6759,7 @@ "resolved": "https://registry.npmjs.org/convex/-/convex-1.24.1.tgz", "integrity": "sha512-LucaIBktohO7KuKKsxLXxGhKoxYqcLUFqOXoC2p0HU4f8lWKNmDv1ackTB4DU5u/thnh/tIOvySEVNdoAdGXAw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "esbuild": "0.25.2", "jwt-decode": "^4.0.0", @@ -7040,6 +7051,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -7237,8 +7249,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -7397,7 +7408,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -7741,6 +7753,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10328,7 +10341,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11213,6 +11225,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11471,7 +11484,6 @@ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", "license": "MIT", - "peer": true, "peerDependencies": { "preact": ">=10" } @@ -11507,7 +11519,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11523,7 +11534,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -11717,6 +11727,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11743,6 +11754,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11785,6 +11797,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.2.tgz", "integrity": "sha512-vpfuHuQMF/L6GpuQ4c3ZDo+pRYxIi40gQqsCmmfUBwm+oqvBhKhwghCuj2o00YCgSfU6bR9KC/xnQGWm3Gr08A==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -11801,8 +11814,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-promise-suspense": { "version": "0.3.4", @@ -11881,6 +11893,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -11903,6 +11916,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", "license": "MIT", + "peer": true, "dependencies": { "react-router": "7.12.0" }, @@ -12788,6 +12802,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", @@ -13094,6 +13109,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13389,6 +13405,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13484,6 +13501,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13694,6 +13712,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13967,6 +13986,7 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14083,6 +14103,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14096,6 +14117,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -14477,6 +14499,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 8fb78b7c..b4e23774 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "@dnd-kit/sortable": "^10.0.0", "@fontsource/figtree": "^5.1.0", "@hookform/resolvers": "^3.9.0", - "@ianpaschal/combat-command-components": "^1.7.2", - "@ianpaschal/combat-command-game-systems": "^1.1.4", + "@ianpaschal/combat-command-components": "^1.8.0", + "@ianpaschal/combat-command-game-systems": "^1.2.0", "@mapbox/search-js-core": "^1.0.0-beta.25", "@radix-ui/colors": "^3.0.0", "@react-hook/window-size": "^3.1.1", diff --git a/src/api.ts b/src/api.ts index 174a1343..1eedca19 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,6 +5,12 @@ import { Id } from '../convex/_generated/dataModel'; export { api }; // Common +export { + type Alignment, +} from '../convex/_model/common/alignment'; +export { + type Faction, +} from '../convex/_model/common/faction'; export type { RankingFactor, TournamentStatus, diff --git a/src/components/AlignmentGraph/AlignmentGraph.module.scss b/src/components/AlignmentGraph/AlignmentGraph.module.scss new file mode 100644 index 00000000..e69df90e --- /dev/null +++ b/src/components/AlignmentGraph/AlignmentGraph.module.scss @@ -0,0 +1,37 @@ +.AlignmentGraph { + display: grid; + grid-template-areas: + "anchor_left center_mark anchor_right" + "graph graph graph"; + grid-template-columns: 1fr auto 1fr; + grid-template-rows: auto auto; + gap: 0.5rem; + + &_AnchorLeft { + grid-area: anchor_left; + align-self: center; + } + + &_CenterMark { + transform: translateY(0.5rem); + + grid-area: center_mark; + align-self: end; + justify-self: center; + + width: 1.25rem; + height: 1.25rem; + + color: var(--text-primary); + } + + &_AnchorRight { + grid-area: anchor_right; + align-self: center; + justify-self: end; + } + + &_Graph { + grid-area: graph; + } +} diff --git a/src/components/AlignmentGraph/AlignmentGraph.tsx b/src/components/AlignmentGraph/AlignmentGraph.tsx new file mode 100644 index 00000000..bd020b5b --- /dev/null +++ b/src/components/AlignmentGraph/AlignmentGraph.tsx @@ -0,0 +1,127 @@ +import { RatioBar, RatioBarSection } from '@ianpaschal/combat-command-components'; +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { Alignment as FlamesOfWarV4 } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; +import { Alignment as TeamYankeeV2 } from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; +import { + blue, + blueDark, + gray, + grayDark, + purple, + purpleDark, + tomato, + tomatoDark, +} from '@radix-ui/colors'; +import clsx from 'clsx'; +import { ChevronDown } from 'lucide-react'; + +import { Alignment } from '~/api'; +import { useTheme } from '~/components/ThemeProvider'; +import { useTournament } from '~/components/TournamentProvider'; +import { useGetTournamentRegistrationsByTournament } from '~/services/tournamentRegistrations'; + +import styles from './AlignmentGraph.module.scss'; + +export interface AlignmentGraphProps { + className?: string; +} + +export const AlignmentGraph = ({ + className, +}: AlignmentGraphProps): JSX.Element => { + const { theme } = useTheme(); + const tournament = useTournament(); + + const { blue9: themeBlue } = theme === 'dark' ? blueDark : blue; + const { purple9: themePurple } = theme === 'dark' ? purpleDark : purple; + const { tomato9: themeRed } = theme === 'dark' ? tomatoDark : tomato; + const { gray3: themeGray } = theme === 'dark' ? grayDark : gray; + + const { data: tournamentRegistrations, loading } = useGetTournamentRegistrationsByTournament({ + tournamentId: tournament._id, + }); + + let leftLabel = ''; + let rightLabel = ''; + const sections: RatioBarSection[] = []; + + // Show loading state as a pale grey bar + if (loading || tournamentRegistrations === undefined) { + sections.push({ + label: 'Loading', + value: 1, + color: themeGray, + }); + } else { + const alignmentGroups = tournamentRegistrations.reduce((acc, { alignments }) => { + if (alignments.length) { + for (const alignment of alignments) { + acc[alignment] = (acc[alignment] ?? 0) + 1; + } + } else { + acc['unknown'] = (acc['unknown'] ?? 0) + 1; + } + + return acc; + }, {} as Partial>); + + if (tournament.gameSystem === GameSystem.FlamesOfWarV4) { + leftLabel = 'Allies'; + rightLabel = 'Axis'; + sections.push(...[ + { + label: leftLabel, + value: alignmentGroups[FlamesOfWarV4.Allies] ?? 0, + color: themeBlue, + }, + { + label: 'Flexible', + value: alignmentGroups[FlamesOfWarV4.Flexible] ?? 0, + color: themePurple, + }, + { + label: rightLabel, + value: alignmentGroups[FlamesOfWarV4.Axis] ?? 0, + color: themeRed, + }, + ]); + } + + if (tournament.gameSystem === GameSystem.TeamYankeeV2) { + leftLabel = 'NATO'; + rightLabel = 'Warsaw Pact'; + sections.push(...[ + { + label: leftLabel, + value: alignmentGroups[TeamYankeeV2.Nato] ?? 0, + color: themeBlue, + }, + { + label: 'Flexible', + value: alignmentGroups[TeamYankeeV2.Flexible] ?? 0, + color: themePurple, + }, + { + label: rightLabel, + value: alignmentGroups[TeamYankeeV2.WarsawPact] ?? 0, + color: themeRed, + }, + ]); + } + + sections.push({ + label: 'Unknown', + value: alignmentGroups['unknown'] ?? 0, + color: themeGray, + }); + } + + return ( +
+

{leftLabel}

+ +

{rightLabel}

+ +
+ ); +}; diff --git a/src/components/AlignmentGraph/index.ts b/src/components/AlignmentGraph/index.ts new file mode 100644 index 00000000..ed33f96d --- /dev/null +++ b/src/components/AlignmentGraph/index.ts @@ -0,0 +1,3 @@ +export { + AlignmentGraph, +} from './AlignmentGraph'; diff --git a/src/components/FactionIndicator/FactionIndicator.module.scss b/src/components/FactionIndicator/FactionIndicator.module.scss new file mode 100644 index 00000000..bab973d7 --- /dev/null +++ b/src/components/FactionIndicator/FactionIndicator.module.scss @@ -0,0 +1,54 @@ +@use "/src/style/variables"; +@use "/src/style/flex"; + +@import "@radix-ui/colors/blue-dark.css"; +@import "@radix-ui/colors/blue.css"; +@import "@radix-ui/colors/tomato-dark.css"; +@import "@radix-ui/colors/tomato.css"; +@import "@radix-ui/colors/plum-dark.css"; +@import "@radix-ui/colors/plum.css"; +@import "@radix-ui/colors/gray-dark.css"; +@import "@radix-ui/colors/gray.css"; + +.FactionIndicator { + --red: var(--tomato-9); + --blue: var(--blue-9); + --background: linear-gradient(90deg, var(--blue) 50%, var(--red) 50%); + + position: relative; + + flex: 0 0 auto; + align-self: center; + + width: 1.5rem; + min-width: 1.5rem; + height: 1.5rem; + min-height: 1.5rem; + + &[data-color="red"] { + --background: var(--red); + } + + &[data-color="blue"] { + --background: var(--blue); + } + + &_Ring { + position: absolute; + inset: -0.25rem; + + opacity: 0.25; + background: var(--background); + border-radius: 50%; + } + + &_Center { + position: absolute; + inset: 0; + background: var(--background); + border-radius: 50%; + + // outline: var(--border-width) solid var(--border-color-default); + // outline-offset: calc(-1 * var(--border-width)); + } +} diff --git a/src/components/FactionIndicator/FactionIndicator.tsx b/src/components/FactionIndicator/FactionIndicator.tsx new file mode 100644 index 00000000..9cbedb6e --- /dev/null +++ b/src/components/FactionIndicator/FactionIndicator.tsx @@ -0,0 +1,36 @@ +import { getAlignmentDisplayName } from '@ianpaschal/combat-command-game-systems/common'; +import clsx from 'clsx'; + +import { Alignment, Faction } 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[]; + className?: string; +} + +export const FactionIndicator = ({ + alignments, + // factions, + className, +}: FactionIndicatorProps): JSX.Element => { + + // FIXME: Be smarter about showing competitors (teams) with multiple alignments + const primaryAlignment = alignments[0]; + + const color = getAlignmentColor(primaryAlignment); + const displayName = getAlignmentDisplayName(primaryAlignment) ?? 'Unknown Alignment'; + + return ( + +
+
+
+
+ + ); +}; diff --git a/src/components/FactionIndicator/FactionIndicator.utils.ts b/src/components/FactionIndicator/FactionIndicator.utils.ts new file mode 100644 index 00000000..ac64e25a --- /dev/null +++ b/src/components/FactionIndicator/FactionIndicator.utils.ts @@ -0,0 +1,22 @@ +import { Alignment as FlamesOfWarV4 } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; +import { Alignment as TeamYankeeV2 } from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; + +import { Alignment } from '~/api'; + +export const getAlignmentColor = ( + alignment?: Alignment, +): 'red' | 'blue' | 'mixed' => { + if (alignment === FlamesOfWarV4.Allies) { + return 'blue'; + } + if (alignment === FlamesOfWarV4.Axis) { + return 'red'; + } + if (alignment === TeamYankeeV2.Nato) { + return 'blue'; + } + if (alignment === TeamYankeeV2.WarsawPact) { + return 'red'; + } + return 'mixed'; +}; diff --git a/src/components/FactionIndicator/index.ts b/src/components/FactionIndicator/index.ts new file mode 100644 index 00000000..427d1076 --- /dev/null +++ b/src/components/FactionIndicator/index.ts @@ -0,0 +1,4 @@ +export { + FactionIndicator, + type FactionIndicatorProps, +} from './FactionIndicator'; diff --git a/src/components/IdentityBadge/TournamentCompetitorAvatar.tsx b/src/components/IdentityBadge/TournamentCompetitorAvatar.tsx index 7862f8fd..9a08ff49 100644 --- a/src/components/IdentityBadge/TournamentCompetitorAvatar.tsx +++ b/src/components/IdentityBadge/TournamentCompetitorAvatar.tsx @@ -25,9 +25,9 @@ export const TournamentCompetitorAvatar = forwardRef r.active); // If competitor has only 1 player, just use the player's avatar: - if (!isTeam && activeRegistrations[0].user) { + if (!isTeam && activeRegistrations[0]?.user) { return ( - + ); } diff --git a/src/components/ThemeProvider/ThemeProvider.context.ts b/src/components/ThemeProvider/ThemeProvider.context.ts index aa2c3ac7..e823b51b 100644 --- a/src/components/ThemeProvider/ThemeProvider.context.ts +++ b/src/components/ThemeProvider/ThemeProvider.context.ts @@ -1,5 +1,11 @@ import { createContext } from 'react'; import { ThemePreference } from '~/api'; +import { ResolvedTheme } from './ThemeProvider.hooks'; -export const ThemeContext = createContext(null); +export interface ThemeContextValue { + preference: ThemePreference; + theme: ResolvedTheme; +} + +export const ThemeContext = createContext(null); diff --git a/src/components/ThemeProvider/ThemeProvider.hooks.ts b/src/components/ThemeProvider/ThemeProvider.hooks.ts index 7b671ab0..a55dcbd5 100644 --- a/src/components/ThemeProvider/ThemeProvider.hooks.ts +++ b/src/components/ThemeProvider/ThemeProvider.hooks.ts @@ -1,11 +1,45 @@ -import { useContext } from 'react'; +import { + useContext, + useEffect, + useState, +} from 'react'; +import { ThemePreference } from '~/api'; import { ThemeContext } from './ThemeProvider.context'; +export type ResolvedTheme = 'light' | 'dark'; + +export const useResolvedTheme = (preference: ThemePreference): ResolvedTheme => { + const [resolvedTheme, setResolvedTheme] = useState(() => { + if (preference === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return preference; + }); + + useEffect(() => { + if (preference !== 'system') { + setResolvedTheme(preference); + return; + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const updateTheme = () => { + setResolvedTheme(mediaQuery.matches ? 'dark' : 'light'); + }; + + updateTheme(); + mediaQuery.addEventListener('change', updateTheme); + return () => mediaQuery.removeEventListener('change', updateTheme); + }, [preference]); + + return resolvedTheme; +}; + export const useTheme = () => { const context = useContext(ThemeContext); if (!context) { - throw Error('useTournament must be used within a or !'); + throw Error('useTheme must be used within a !'); } return context; }; diff --git a/src/components/ThemeProvider/ThemeProvider.tsx b/src/components/ThemeProvider/ThemeProvider.tsx index 742fa9e9..fe47f101 100644 --- a/src/components/ThemeProvider/ThemeProvider.tsx +++ b/src/components/ThemeProvider/ThemeProvider.tsx @@ -2,6 +2,7 @@ import { ReactNode, useEffect } from 'react'; import { useGetUserPreferences } from '~/services/userPreferences'; import { ThemeContext } from './ThemeProvider.context'; +import { useResolvedTheme } from './ThemeProvider.hooks'; export interface ThemeProviderProps { children: ReactNode; @@ -11,32 +12,19 @@ export const ThemeProvider = ({ children, }: ThemeProviderProps) => { const { data: userPreferences } = useGetUserPreferences({}); - const theme = userPreferences?.theme || 'system'; + const preference = userPreferences?.theme || 'system'; + const theme = useResolvedTheme(preference); + useEffect(() => { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const updateClass = () => { - if (theme === 'system') { - if (mediaQuery.matches) { - document.body.classList.add('dark'); - } else { - document.body.classList.remove('dark'); - } - } else if (theme === 'dark') { - document.body.classList.add('dark'); - } else { - document.body.classList.remove('dark'); - } - }; - updateClass(); // Set initial state - if (theme === 'system') { - mediaQuery.addEventListener('change', updateClass); // Listen for changes + if (theme === 'dark') { + document.body.classList.add('dark'); } else { - mediaQuery.removeEventListener('change', updateClass); + document.body.classList.remove('dark'); } - return () => mediaQuery.removeEventListener('change', updateClass); // Cleanup }, [theme]); + return ( - + {children} ); diff --git a/src/components/TournamentCompetitorProvider/actions/useToggleActiveAction.tsx b/src/components/TournamentCompetitorProvider/actions/useToggleActiveAction.tsx index f76a8a9d..d5825c4f 100644 --- a/src/components/TournamentCompetitorProvider/actions/useToggleActiveAction.tsx +++ b/src/components/TournamentCompetitorProvider/actions/useToggleActiveAction.tsx @@ -9,7 +9,7 @@ export const useToggleActiveAction = ( subject: TournamentCompetitor, ): ActionDefinition | null => { const { mutation } = useToggleTournamentCompetitorActive({ - onSuccess: () => toast.success(`${subject.displayName} is now active!`), + onSuccess: (active): void => toast.success(`${subject.displayName} is now ${active ? 'active' : 'inactive'}.`), }); if (subject.availableActions.includes(KEY)) { return { diff --git a/src/components/TournamentForm/TournamentForm.schema.ts b/src/components/TournamentForm/TournamentForm.schema.ts index 6fdd909d..42bf4a16 100644 --- a/src/components/TournamentForm/TournamentForm.schema.ts +++ b/src/components/TournamentForm/TournamentForm.schema.ts @@ -64,6 +64,13 @@ export const tournamentFormSchema = z.object({ amount: z.coerce.number().min(0), currency: z.string().transform((val) => val as CurrencyCode), }), + registrationDetails: z.optional(z.object({ + alignment: z.union([z.literal('optional'), z.literal('required'), z.null()]), + faction: z.union([z.literal('optional'), z.literal('required'), z.null()]), + })), + + alignmentsRevealed: z.boolean().optional().default(false), + factionsRevealed: z.boolean().optional().default(false), // Format Config roundCount: z.coerce.number(), @@ -81,6 +88,14 @@ export const tournamentFormSchema = z.object({ }).refine(validateGameSystemConfig, { message: 'Invalid config for the selected game system.', path: ['gameSystemConfig'], // Highlight the game_system_config field in case of error +}).superRefine((data, ctx) => { + if (data.pairingMethod === TournamentPairingMethod.AdjacentAlignment && data.competitorSize > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Adjacent alignment (red vs. blue) pairing is not available for team competitions.', + path: ['pairingMethod'], + }); + } }); export const tournamentFormResolver = zodResolver(tournamentFormSchema); @@ -113,7 +128,13 @@ export const defaultValues: Omit, 'location rankingFactors: ['total_wins'], logoStorageId: '' as StorageId, bannerStorageId: '' as StorageId, - editionYear: 2025, + editionYear: new Date().getFullYear(), + alignmentsRevealed: false, + factionsRevealed: false, + registrationDetails: { + alignment: 'optional', + faction: null, + }, startsAt: (() => { const date = new Date(); date.setDate(date.getDate() + 14); diff --git a/src/components/TournamentForm/components/CompetitorFields.tsx b/src/components/TournamentForm/components/CompetitorFields.tsx index df731586..5a8742d9 100644 --- a/src/components/TournamentForm/components/CompetitorFields.tsx +++ b/src/components/TournamentForm/components/CompetitorFields.tsx @@ -1,7 +1,10 @@ import { useFormContext } from 'react-hook-form'; +import { Select } from '@ianpaschal/combat-command-components'; +import { TournamentPairingMethod } from '@ianpaschal/combat-command-game-systems/common'; import clsx from 'clsx'; import { Animate } from '~/components/generic/Animate'; +import { Checkbox } from '~/components/generic/Checkbox'; import { FormField } from '~/components/generic/Form'; import { InputCurrency } from '~/components/generic/InputCurrency'; import { InputText } from '~/components/generic/InputText'; @@ -21,7 +24,7 @@ export const CompetitorFields = ({ status = 'draft', }: CompetitorFieldsProps): JSX.Element => { const { reset, watch } = useFormContext(); - const { maxCompetitors, competitorSize } = watch(); + const { maxCompetitors, competitorSize, pairingMethod } = watch(); // TODO: Implement later // const { fields: competitorGroupFields, append, remove } = useFieldArray({ @@ -78,6 +81,7 @@ export const CompetitorFields = ({ // Once a tournament is published, lock some fields const allowedEditStatuses = ['draft']; const disableFields = !allowedEditStatuses.includes(status); + const disableAlignmentField = pairingMethod === TournamentPairingMethod.AdjacentAlignment; return (
@@ -105,9 +109,35 @@ export const CompetitorFields = ({ - +

Registration Questions

+ + + + + + + + +
); }; diff --git a/src/components/TournamentForm/components/FormatFields.tsx b/src/components/TournamentForm/components/FormatFields.tsx index 30c0e233..93395d3d 100644 --- a/src/components/TournamentForm/components/FormatFields.tsx +++ b/src/components/TournamentForm/components/FormatFields.tsx @@ -1,5 +1,6 @@ +import { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; -import { getTournamentPairingMethodOptions } from '@ianpaschal/combat-command-game-systems/common'; +import { getTournamentPairingMethodOptions, TournamentPairingMethod } from '@ianpaschal/combat-command-game-systems/common'; import { Animate } from '~/components/generic/Animate'; import { FormField } from '~/components/generic/Form'; @@ -20,10 +21,16 @@ export interface FormatFieldsProps { export const FormatFields = ({ status = 'draft', }: FormatFieldsProps): JSX.Element => { - const { watch } = useFormContext(); - const { roundStructure, competitorSize } = watch(); + const { watch, setValue } = useFormContext(); + const { roundStructure, competitorSize, pairingMethod } = watch(); const isTeam = competitorSize > 1; + useEffect(() => { + if (pairingMethod === TournamentPairingMethod.AdjacentAlignment) { + setValue('registrationDetails.alignment', 'required'); + } + }, [pairingMethod, setValue]); + // Once a tournament is active, lock some fields const allowedEditStatuses = ['draft', 'published']; const disableFields = !allowedEditStatuses.includes(status); diff --git a/src/components/TournamentProvider/TournamentContextMenu.tsx b/src/components/TournamentProvider/TournamentContextMenu.tsx index 6904303b..db4a6fcc 100644 --- a/src/components/TournamentProvider/TournamentContextMenu.tsx +++ b/src/components/TournamentProvider/TournamentContextMenu.tsx @@ -28,6 +28,7 @@ export const TournamentContextMenu = ({ // Lifecycle actions[TournamentActionKey.Publish], actions[TournamentActionKey.Cancel], + actions[TournamentActionKey.ToggleAlignmentsRevealed], actions[TournamentActionKey.Start], actions[TournamentActionKey.ConfigureRound], actions[TournamentActionKey.StartRound], diff --git a/src/components/TournamentProvider/TournamentProvider.hooks.tsx b/src/components/TournamentProvider/TournamentProvider.hooks.tsx index fc670a4c..eaae3add 100644 --- a/src/components/TournamentProvider/TournamentProvider.hooks.tsx +++ b/src/components/TournamentProvider/TournamentProvider.hooks.tsx @@ -14,6 +14,7 @@ import { usePublishAction } from './actions/usePublishAction'; import { useStartAction } from './actions/useStartAction'; import { useStartRoundAction } from './actions/useStartRoundAction'; import { useSubmitMatchResultAction } from './actions/useSubmitMatchResultAction'; +import { useToggleAlignmentsRevealedAction } from './actions/useToggleAlignmentsRevealedAction'; import { useUndoStartRoundAction } from './actions/useUndoStartRoundAction'; import { tournamentContext } from './TournamentProvider.context'; @@ -35,6 +36,7 @@ export const useActions = ( // Lifecycle usePublishAction(subject), + useToggleAlignmentsRevealedAction(subject), useStartAction(subject), useConfigureRoundAction(subject), useStartRoundAction(subject), diff --git a/src/components/TournamentProvider/actions/useToggleAlignmentsRevealedAction.ts b/src/components/TournamentProvider/actions/useToggleAlignmentsRevealedAction.ts new file mode 100644 index 00000000..796b6457 --- /dev/null +++ b/src/components/TournamentProvider/actions/useToggleAlignmentsRevealedAction.ts @@ -0,0 +1,22 @@ +import { Tournament, TournamentActionKey } from '~/api'; +import { ActionDefinition } from '~/components/ContextMenu/ContextMenu.types'; +import { toast } from '~/components/ToastProvider'; +import { useToggleTournamentAlignmentsRevealed } from '~/services/tournaments'; + +const KEY = TournamentActionKey.ToggleAlignmentsRevealed; + +export const useToggleAlignmentsRevealedAction = ( + subject: Tournament, +): ActionDefinition | null => { + const { mutation } = useToggleTournamentAlignmentsRevealed({ + onSuccess: (revealed): void => toast.success(`${subject.displayName} is now ${revealed ? 'active' : 'inactive'}.`), + }); + if (subject.availableActions.includes(KEY)) { + return { + key: KEY, + label: subject.alignmentsRevealed ? 'Hide Alignments' : 'Reveal Alignments', + handler: () => mutation({ id: subject._id }), + }; + } + return null; +}; diff --git a/src/components/TournamentProvider/index.ts b/src/components/TournamentProvider/index.ts index d7d2c74f..802b54b4 100644 --- a/src/components/TournamentProvider/index.ts +++ b/src/components/TournamentProvider/index.ts @@ -9,6 +9,7 @@ export { usePublishAction } from './actions/usePublishAction'; export { useStartAction } from './actions/useStartAction'; export { useStartRoundAction } from './actions/useStartRoundAction'; export { useSubmitMatchResultAction } from './actions/useSubmitMatchResultAction'; +export { useToggleAlignmentsRevealedAction } from './actions/useToggleAlignmentsRevealedAction'; export { useUndoStartRoundAction } from './actions/useUndoStartRoundAction'; export { TournamentContextMenu, diff --git a/src/components/TournamentRegistrationForm/TournamentRegistrationForm.schema.ts b/src/components/TournamentRegistrationForm/TournamentRegistrationForm.schema.ts index 6168d017..2db94ba5 100644 --- a/src/components/TournamentRegistrationForm/TournamentRegistrationForm.schema.ts +++ b/src/components/TournamentRegistrationForm/TournamentRegistrationForm.schema.ts @@ -1,3 +1,6 @@ +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { registrationDetails as flamesOfWarV4 } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; +import { registrationDetails as teamYankeeV2 } from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; import { z } from 'zod'; import { @@ -9,15 +12,30 @@ import { } from '~/api'; import { nameVisibilityChangeRequired } from '~/components/TournamentRegistrationForm/TournamentRegistrationForm.utils'; +const getDetailsSchema = (tournament: Tournament) => { + const requiredFields = { + alignment: tournament.registrationDetails?.alignment === 'required', + faction: tournament.registrationDetails?.faction === 'required', + }; + switch (tournament.gameSystem) { + case GameSystem.FlamesOfWarV4: + return flamesOfWarV4.createSchema(requiredFields); + case GameSystem.TeamYankeeV2: + return teamYankeeV2.createSchema(requiredFields); + default: + throw new Error(`Unsupported game system: ${tournament.gameSystem}`); + } +}; + // Helper to convert empty strings and null to undefined const emptyToUndefined = (schema: T) => z.preprocess((val) => (val === '' || val === null ? undefined : val), schema); export const createSchema = (tournament: Tournament, currentUser: User | null) => z.object({ - tournamentCompetitor: emptyToUndefined(z.object({ + tournamentCompetitor: z.object({ teamName: emptyToUndefined(z.string({ message: 'Please provide a team name.', }).min(2, 'Must be at least 2 characters.').optional()), // FIXME: THIS IS NOT WORKING B/C country library interprets 2 and 3 char strings as country codes - }).optional()), + }).optional(), tournamentCompetitorId: emptyToUndefined(z.string({ message: 'Please select a team.', }).transform((val) => val as TournamentCompetitorId).optional()), @@ -28,6 +46,7 @@ export const createSchema = (tournament: Tournament, currentUser: User | null) = message: 'Please select a user.', }).transform((val) => val as UserId), nameVisibilityConsent: z.boolean().optional(), + details: getDetailsSchema(tournament), }).superRefine((values, ctx) => { if (tournament.useTeams && !values.tournamentCompetitorId && !values.tournamentCompetitor?.teamName) { ctx.addIssue({ @@ -48,6 +67,10 @@ export const createSchema = (tournament: Tournament, currentUser: User | null) = export type SubmitData = z.infer>; export type FormData = { + details: { + alignment: string | null; + faction: string | null; + }; tournamentCompetitor: { teamName: string; }; @@ -58,6 +81,10 @@ export type FormData = { }; export const defaultValues: FormData = { + details: { + alignment: null, + faction: null, + }, tournamentCompetitor: { teamName: '', }, diff --git a/src/components/TournamentRegistrationForm/TournamentRegistrationForm.tsx b/src/components/TournamentRegistrationForm/TournamentRegistrationForm.tsx index bb1af655..1ce49666 100644 --- a/src/components/TournamentRegistrationForm/TournamentRegistrationForm.tsx +++ b/src/components/TournamentRegistrationForm/TournamentRegistrationForm.tsx @@ -13,6 +13,7 @@ import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCo import { useGetTournamentRegistrationsByTournament } from '~/services/tournamentRegistrations'; import { getEtcCountryOptions } from '~/utils/common/getCountryOptions'; import { validateForm } from '~/utils/validateForm'; +import { DetailsFields } from './components/DetailsFields'; import { createSchema, defaultValues, @@ -137,6 +138,9 @@ export const TournamentRegistrationForm = ({
)} + {tournament.registrationDetails && ( + + )} {showNameVisibilityConsentField && ( diff --git a/src/components/TournamentRegistrationForm/components/DetailsFields/DetailsFields.module.scss b/src/components/TournamentRegistrationForm/components/DetailsFields/DetailsFields.module.scss new file mode 100644 index 00000000..ccf72fa8 --- /dev/null +++ b/src/components/TournamentRegistrationForm/components/DetailsFields/DetailsFields.module.scss @@ -0,0 +1,5 @@ +@use "/src/style/flex"; + +.FactionFields { + @include flex.column; +} diff --git a/src/components/TournamentRegistrationForm/components/DetailsFields/DetailsFields.tsx b/src/components/TournamentRegistrationForm/components/DetailsFields/DetailsFields.tsx new file mode 100644 index 00000000..f05eee2f --- /dev/null +++ b/src/components/TournamentRegistrationForm/components/DetailsFields/DetailsFields.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { Select } from '@ianpaschal/combat-command-components'; +import { getGameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import clsx from 'clsx'; + +import { Tournament } from '~/api'; +import { FormField } from '~/components/generic/Form'; +import { FormData } from '../../TournamentRegistrationForm.schema'; + +import styles from './DetailsFields.module.scss'; + +export interface DetailsFieldsProps { + className?: string; + tournament: Tournament; +} + +export const DetailsFields = ({ + className, + tournament, +}: DetailsFieldsProps): JSX.Element => { + const { + getFactionOptions, + getAlignmentOptions, + getFactionAlignment, + } = getGameSystem(tournament.gameSystem); + const { setValue, watch } = useFormContext(); + const declaredFaction = watch('details.faction'); + + useEffect(() => { + if (declaredFaction) { + setValue('details.alignment', getFactionAlignment(declaredFaction) ?? null); + } + }, [declaredFaction, getFactionAlignment, setValue]); + + const showAlignmentField = !!tournament.registrationDetails?.alignment; + const showFactionField = !!tournament.registrationDetails?.faction; + + return ( +
+ {showAlignmentField && ( + + + + )} +
+ ); +}; diff --git a/src/components/TournamentRegistrationForm/components/DetailsFields/index.ts b/src/components/TournamentRegistrationForm/components/DetailsFields/index.ts new file mode 100644 index 00000000..6efd19fa --- /dev/null +++ b/src/components/TournamentRegistrationForm/components/DetailsFields/index.ts @@ -0,0 +1,4 @@ +export { + DetailsFields, + type DetailsFieldsProps, +} from './DetailsFields'; diff --git a/src/pages/TournamentDetailPage/components/TournamentDetailCard/TournamentDetailCard.module.scss b/src/pages/TournamentDetailPage/components/TournamentDetailCard/TournamentDetailCard.module.scss index 27940bd0..d02a7a85 100644 --- a/src/pages/TournamentDetailPage/components/TournamentDetailCard/TournamentDetailCard.module.scss +++ b/src/pages/TournamentDetailPage/components/TournamentDetailCard/TournamentDetailCard.module.scss @@ -8,7 +8,7 @@ @use "/src/style/variables"; .TournamentDetailCard { - @include flex.column($gap: 0); + @include flex.column($gap: 2rem); overflow: hidden; @@ -18,12 +18,16 @@ position: relative; box-sizing: content-box; // Make the height apply to the inside, without padding - padding: 1rem var(--container-padding-x); + padding: 1rem var(--container-padding-x) 0; &_Actions { @include flex.row($gap: 0.5rem, $xAlign: center); margin-left: auto; } + + h2 { + @include flex.row($yAlign: center); + } } } diff --git a/src/pages/TournamentDetailPage/components/TournamentDetailCard/TournamentDetailCard.tsx b/src/pages/TournamentDetailPage/components/TournamentDetailCard/TournamentDetailCard.tsx index 001d2b16..5f64186f 100644 --- a/src/pages/TournamentDetailPage/components/TournamentDetailCard/TournamentDetailCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentDetailCard/TournamentDetailCard.tsx @@ -2,6 +2,7 @@ import { cloneElement, HTMLAttributes, ReactElement, + ReactNode, } from 'react'; import clsx from 'clsx'; @@ -9,8 +10,8 @@ import { Card } from '~/components/generic/Card'; import styles from './TournamentDetailCard.module.scss'; -export interface TournamentDetailCardProps extends HTMLAttributes { - title?: string; +export interface TournamentDetailCardProps extends Omit, 'title'> { + title?: ReactNode; buttons?: ReactElement[]; } diff --git a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.module.scss b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.module.scss index b1a72389..f43fe683 100644 --- a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.module.scss +++ b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.module.scss @@ -3,8 +3,6 @@ @use "/src/style/text"; .TournamentRosterCard { - padding-bottom: var(--container-padding-y); - &_Filters { @include flex.row; } @@ -17,6 +15,10 @@ padding: 4rem; } + &_Graphs { + padding: 0 var(--container-padding-x); + } + &_Table { --table-padding: var(--container-padding-x); --cell-padding-y: 1rem; diff --git a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx index 30fa2463..d8d06e33 100644 --- a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx @@ -1,6 +1,6 @@ import { ReactElement } from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; -import { Table } from '@ianpaschal/combat-command-components'; +import { Button, Table } from '@ianpaschal/combat-command-components'; import clsx from 'clsx'; import { EyeOff, @@ -9,9 +9,10 @@ import { } from 'lucide-react'; import { TournamentActionKey } from '~/api'; +import { AlignmentGraph } from '~/components/AlignmentGraph'; import { useAuth } from '~/components/AuthProvider'; import { EmptyState } from '~/components/EmptyState'; -import { Button } from '~/components/generic/Button'; +import { Tag } from '~/components/generic/Tag'; import { toast } from '~/components/ToastProvider'; import { useActions, useTournament } from '~/components/TournamentProvider'; import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; @@ -88,20 +89,37 @@ export const TournamentRosterCard = ({ return ( + Roster + + {`${tournament.playerCount}/${tournament.maxPlayers}`} + + + )} buttons={getControls()} > - {showLoadingState ? ( -
- Loading... -
- ) : ( - showEmptyState ? ( - + { + showLoadingState ? ( +
+ Loading... +
) : ( - - ) - )} - + showEmptyState ? ( + + ) : ( + <> + {(tournament.alignmentsVisible || tournament.factionsVisible) && ( +
+ {tournament.alignmentsVisible && ( + + )} +
+ )} +
+ + ) + )} + ); }; diff --git a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.utils.tsx b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.utils.tsx index face8d48..5f436b23 100644 --- a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.utils.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.utils.tsx @@ -3,6 +3,7 @@ import { ColumnDef } from '@ianpaschal/combat-command-components'; import { ChevronRight } from 'lucide-react'; import { Tournament, TournamentCompetitor } from '~/api'; +import { FactionIndicator } from '~/components/FactionIndicator'; import { Button } from '~/components/generic/Button'; import { IdentityBadge } from '~/components/IdentityBadge'; import { TournamentCompetitorActiveToggle, TournamentCompetitorPlayerCount } from '~/components/TournamentCompetitorProvider'; @@ -15,32 +16,36 @@ export const getTournamentCompetitorTableConfig = ( { key: 'identity', label: tournament.useTeams ? 'Team' : 'Player', - width: '1fr', xAlign: 'left', renderCell: (r) => ( ), }, - ...(tournament.useTeams ? [ - { - key: 'players', - label: 'Players', - xAlign: 'left', - renderCell: (r) => ( - - ), - } as ColumnDef, - ] : []), - ...(tournament.status === 'active' ? [ - { - key: 'active', - label: 'Ready', - xAlign: 'center', - renderCell: (r) => ( - - ), - } as ColumnDef, - ] : []), + ...(tournament.alignmentsVisible ? [{ + key: 'alignments', + xAlign: 'center', + width: 100, + renderCell: (r) => ( + + ), + } as ColumnDef] : []), + { key: 'spacer', width: '1fr' }, + ...(tournament.useTeams ? [{ + key: 'players', + label: 'Players', + xAlign: 'left', + renderCell: (r) => ( + + ), + } as ColumnDef] : []), + ...(tournament.status === 'active' ? [{ + key: 'active', + label: 'Ready', + xAlign: 'center', + renderCell: (r) => ( + + ), + } as ColumnDef] : []), { key: 'viewDetails', xAlign: 'center', diff --git a/src/services/tournaments.ts b/src/services/tournaments.ts index ca812eb8..3d0ceac1 100644 --- a/src/services/tournaments.ts +++ b/src/services/tournaments.ts @@ -22,6 +22,7 @@ export const useEndTournamentRound = createMutationHook(api.tournaments.endTourn export const usePublishTournament = createMutationHook(api.tournaments.publishTournament); export const useStartTournament = createMutationHook(api.tournaments.startTournament); export const useStartTournamentRound = createMutationHook(api.tournaments.startTournamentRound); +export const useToggleTournamentAlignmentsRevealed = createMutationHook(api.tournaments.toggleTournamentAlignmentsRevealed); // Actions export const useExportFowV4TournamentMatchData = createActionHook(api.tournaments.exportFowV4TournamentMatchData); diff --git a/src/style/_variables.scss b/src/style/_variables.scss index 7cc51879..1c60a6f6 100644 --- a/src/style/_variables.scss +++ b/src/style/_variables.scss @@ -116,7 +116,7 @@ body { --outlined-default-bg: var(--card-bg); --outlined-default-border-active: var(--gray-6); --outlined-default-border-hover: var(--gray-5); - --outlined-default-border: var(--gray-4); + --outlined-default-border: var(--gray-a4); --outlined-default-text: var(--gray-11); // Outlined - Danger diff --git a/src/style/index.scss b/src/style/index.scss index 3f32db76..f12e8acb 100644 --- a/src/style/index.scss +++ b/src/style/index.scss @@ -2,8 +2,6 @@ * { box-sizing: border-box; - margin: 0; - padding: 0; font-family: Figtree, sans-serif; }