diff --git a/.vscode/settings.json b/.vscode/settings.json index 30fd9ac7..6e729704 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "figtree", "hookform", "Korps", + "Landsknechte", "LFTF", "Mapbox", "merc", diff --git a/convex/_fixtures/fowV4/createMockFowV4MatchResultData.ts b/convex/_fixtures/fowV4/createMockFowV4MatchResultData.ts new file mode 100644 index 00000000..bd97b892 --- /dev/null +++ b/convex/_fixtures/fowV4/createMockFowV4MatchResultData.ts @@ -0,0 +1,31 @@ +import { CreateMatchResultArgs } from '../../_model/matchResults'; + +export const createMockFowV4MatchResultData = ( + data: Partial>, +): CreateMatchResultArgs => { + const outcomeType = Math.random() > 0.25 ? 'objective_taken' : 'time_out'; + return { + playedAt: new Date().toISOString(), + details: { + attacker: 0, + firstTurn: 0, + missionId: 'flames_of_war_v4::mission::2023_04_spearpoint', + outcomeType, + player0BattlePlan: 'attack', + player0UnitsLost: Math.round(Math.random() * 5) + 2, + player1BattlePlan: 'attack', + player1UnitsLost: Math.round(Math.random() * 5) + 2, + turnsPlayed: Math.round(Math.random() * 5) + 2, + winner: outcomeType === 'time_out' ? -1 : (Math.random() > 0.5 ? 1 : 0), + }, + gameSystemConfig: { + points: 100, + eraId: 'flames_of_war_v4::era::late_war', + lessonsFromTheFrontVersionId: 'flames_of_war_v4::lessons_from_the_front_version::2024_03', + missionPackId: 'flames_of_war_v4::mission_pack::2023_04', + missionMatrixId: 'flames_of_war_v4::mission_matrix::2023_04_extended', + }, + gameSystemId: 'flames_of_war_v4', + ...data, + }; +}; diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 35ab2830..0e141492 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,6 +13,7 @@ import type { FilterApi, FunctionReference, } from "convex/server"; +import type * as _fixtures_fowV4_createMockFowV4MatchResultData from "../_fixtures/fowV4/createMockFowV4MatchResultData.js"; import type * as _model_common__helpers_checkAuth from "../_model/common/_helpers/checkAuth.js"; import type * as _model_common__helpers_clamp from "../_model/common/_helpers/clamp.js"; import type * as _model_common__helpers_filterWithSearchTerm from "../_model/common/_helpers/filterWithSearchTerm.js"; @@ -52,6 +53,7 @@ import type * as _model_matchResultLikes_queries_getMatchResultLike from "../_mo import type * as _model_matchResultLikes_queries_getMatchResultLikesByMatchResult from "../_model/matchResultLikes/queries/getMatchResultLikesByMatchResult.js"; import type * as _model_matchResultLikes_queries_getMatchResultLikesByUser from "../_model/matchResultLikes/queries/getMatchResultLikesByUser.js"; import type * as _model_matchResults__helpers_checkMatchResultAuth from "../_model/matchResults/_helpers/checkMatchResultAuth.js"; +import type * as _model_matchResults__helpers_checkMatchResultBattlePlanVisibility from "../_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.js"; import type * as _model_matchResults__helpers_deepenMatchResult from "../_model/matchResults/_helpers/deepenMatchResult.js"; import type * as _model_matchResults__helpers_getShallowMatchResult from "../_model/matchResults/_helpers/getShallowMatchResult.js"; import type * as _model_matchResults_fields from "../_model/matchResults/fields.js"; @@ -66,6 +68,7 @@ import type * as _model_matchResults_queries_getMatchResultsByTournament from ". import type * as _model_matchResults_queries_getMatchResultsByTournamentPairing from "../_model/matchResults/queries/getMatchResultsByTournamentPairing.js"; import type * as _model_matchResults_queries_getMatchResultsByTournamentRound from "../_model/matchResults/queries/getMatchResultsByTournamentRound.js"; import type * as _model_tournamentCompetitors__helpers_deepenTournamentCompetitor from "../_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.js"; +import type * as _model_tournamentCompetitors__helpers_sortTournamentCompetitorsByName from "../_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.js"; import type * as _model_tournamentCompetitors_fields from "../_model/tournamentCompetitors/fields.js"; import type * as _model_tournamentCompetitors_index from "../_model/tournamentCompetitors/index.js"; import type * as _model_tournamentCompetitors_mutations_addTournamentCompetitorPlayer from "../_model/tournamentCompetitors/mutations/addTournamentCompetitorPlayer.js"; @@ -80,13 +83,15 @@ import type * as _model_tournamentCompetitors_queries_getTournamentCompetitorsBy import type * as _model_tournamentPairings__helpers_assignBye from "../_model/tournamentPairings/_helpers/assignBye.js"; import type * as _model_tournamentPairings__helpers_deepenTournamentPairing from "../_model/tournamentPairings/_helpers/deepenTournamentPairing.js"; import type * as _model_tournamentPairings__helpers_generateDraftPairings from "../_model/tournamentPairings/_helpers/generateDraftPairings.js"; -import type * as _model_tournamentPairings__helpers_generateTableAssignments from "../_model/tournamentPairings/_helpers/generateTableAssignments.js"; import type * as _model_tournamentPairings__helpers_getTournamentPairingDeep from "../_model/tournamentPairings/_helpers/getTournamentPairingDeep.js"; import type * as _model_tournamentPairings__helpers_getTournamentPairingShallow from "../_model/tournamentPairings/_helpers/getTournamentPairingShallow.js"; import type * as _model_tournamentPairings__helpers_shuffle from "../_model/tournamentPairings/_helpers/shuffle.js"; import type * as _model_tournamentPairings__helpers_sortByRank from "../_model/tournamentPairings/_helpers/sortByRank.js"; +import type * as _model_tournamentPairings__helpers_sortCompetitorPairs from "../_model/tournamentPairings/_helpers/sortCompetitorPairs.js"; +import type * as _model_tournamentPairings__helpers_sortPairingsByTable from "../_model/tournamentPairings/_helpers/sortPairingsByTable.js"; import type * as _model_tournamentPairings_fields from "../_model/tournamentPairings/fields.js"; import type * as _model_tournamentPairings_index from "../_model/tournamentPairings/index.js"; +import type * as _model_tournamentPairings_mutations_createTournamentPairings from "../_model/tournamentPairings/mutations/createTournamentPairings.js"; import type * as _model_tournamentPairings_queries_getActiveTournamentPairingsByUser from "../_model/tournamentPairings/queries/getActiveTournamentPairingsByUser.js"; import type * as _model_tournamentPairings_queries_getDraftTournamentPairings from "../_model/tournamentPairings/queries/getDraftTournamentPairings.js"; import type * as _model_tournamentPairings_queries_getTournamentPairing from "../_model/tournamentPairings/queries/getTournamentPairing.js"; @@ -112,13 +117,13 @@ import type * as _model_tournaments__helpers_getTournamentShallow from "../_mode import type * as _model_tournaments__helpers_getTournamentUserIds from "../_model/tournaments/_helpers/getTournamentUserIds.js"; import type * as _model_tournaments_fields from "../_model/tournaments/fields.js"; import type * as _model_tournaments_index from "../_model/tournaments/index.js"; -import type * as _model_tournaments_mutations_closeTournamentRound from "../_model/tournaments/mutations/closeTournamentRound.js"; import type * as _model_tournaments_mutations_createTournament from "../_model/tournaments/mutations/createTournament.js"; import type * as _model_tournaments_mutations_deleteTournament from "../_model/tournaments/mutations/deleteTournament.js"; import type * as _model_tournaments_mutations_endTournament from "../_model/tournaments/mutations/endTournament.js"; -import type * as _model_tournaments_mutations_openTournamentRound from "../_model/tournaments/mutations/openTournamentRound.js"; +import type * as _model_tournaments_mutations_endTournamentRound from "../_model/tournaments/mutations/endTournamentRound.js"; 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_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_getTournamentOpenRound from "../_model/tournaments/queries/getTournamentOpenRound.js"; @@ -196,6 +201,7 @@ import type * as utils from "../utils.js"; * ``` */ declare const fullApi: ApiFromModules<{ + "_fixtures/fowV4/createMockFowV4MatchResultData": typeof _fixtures_fowV4_createMockFowV4MatchResultData; "_model/common/_helpers/checkAuth": typeof _model_common__helpers_checkAuth; "_model/common/_helpers/clamp": typeof _model_common__helpers_clamp; "_model/common/_helpers/filterWithSearchTerm": typeof _model_common__helpers_filterWithSearchTerm; @@ -235,6 +241,7 @@ declare const fullApi: ApiFromModules<{ "_model/matchResultLikes/queries/getMatchResultLikesByMatchResult": typeof _model_matchResultLikes_queries_getMatchResultLikesByMatchResult; "_model/matchResultLikes/queries/getMatchResultLikesByUser": typeof _model_matchResultLikes_queries_getMatchResultLikesByUser; "_model/matchResults/_helpers/checkMatchResultAuth": typeof _model_matchResults__helpers_checkMatchResultAuth; + "_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility": typeof _model_matchResults__helpers_checkMatchResultBattlePlanVisibility; "_model/matchResults/_helpers/deepenMatchResult": typeof _model_matchResults__helpers_deepenMatchResult; "_model/matchResults/_helpers/getShallowMatchResult": typeof _model_matchResults__helpers_getShallowMatchResult; "_model/matchResults/fields": typeof _model_matchResults_fields; @@ -249,6 +256,7 @@ declare const fullApi: ApiFromModules<{ "_model/matchResults/queries/getMatchResultsByTournamentPairing": typeof _model_matchResults_queries_getMatchResultsByTournamentPairing; "_model/matchResults/queries/getMatchResultsByTournamentRound": typeof _model_matchResults_queries_getMatchResultsByTournamentRound; "_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor": typeof _model_tournamentCompetitors__helpers_deepenTournamentCompetitor; + "_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName": typeof _model_tournamentCompetitors__helpers_sortTournamentCompetitorsByName; "_model/tournamentCompetitors/fields": typeof _model_tournamentCompetitors_fields; "_model/tournamentCompetitors/index": typeof _model_tournamentCompetitors_index; "_model/tournamentCompetitors/mutations/addTournamentCompetitorPlayer": typeof _model_tournamentCompetitors_mutations_addTournamentCompetitorPlayer; @@ -263,13 +271,15 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentPairings/_helpers/assignBye": typeof _model_tournamentPairings__helpers_assignBye; "_model/tournamentPairings/_helpers/deepenTournamentPairing": typeof _model_tournamentPairings__helpers_deepenTournamentPairing; "_model/tournamentPairings/_helpers/generateDraftPairings": typeof _model_tournamentPairings__helpers_generateDraftPairings; - "_model/tournamentPairings/_helpers/generateTableAssignments": typeof _model_tournamentPairings__helpers_generateTableAssignments; "_model/tournamentPairings/_helpers/getTournamentPairingDeep": typeof _model_tournamentPairings__helpers_getTournamentPairingDeep; "_model/tournamentPairings/_helpers/getTournamentPairingShallow": typeof _model_tournamentPairings__helpers_getTournamentPairingShallow; "_model/tournamentPairings/_helpers/shuffle": typeof _model_tournamentPairings__helpers_shuffle; "_model/tournamentPairings/_helpers/sortByRank": typeof _model_tournamentPairings__helpers_sortByRank; + "_model/tournamentPairings/_helpers/sortCompetitorPairs": typeof _model_tournamentPairings__helpers_sortCompetitorPairs; + "_model/tournamentPairings/_helpers/sortPairingsByTable": typeof _model_tournamentPairings__helpers_sortPairingsByTable; "_model/tournamentPairings/fields": typeof _model_tournamentPairings_fields; "_model/tournamentPairings/index": typeof _model_tournamentPairings_index; + "_model/tournamentPairings/mutations/createTournamentPairings": typeof _model_tournamentPairings_mutations_createTournamentPairings; "_model/tournamentPairings/queries/getActiveTournamentPairingsByUser": typeof _model_tournamentPairings_queries_getActiveTournamentPairingsByUser; "_model/tournamentPairings/queries/getDraftTournamentPairings": typeof _model_tournamentPairings_queries_getDraftTournamentPairings; "_model/tournamentPairings/queries/getTournamentPairing": typeof _model_tournamentPairings_queries_getTournamentPairing; @@ -295,13 +305,13 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/_helpers/getTournamentUserIds": typeof _model_tournaments__helpers_getTournamentUserIds; "_model/tournaments/fields": typeof _model_tournaments_fields; "_model/tournaments/index": typeof _model_tournaments_index; - "_model/tournaments/mutations/closeTournamentRound": typeof _model_tournaments_mutations_closeTournamentRound; "_model/tournaments/mutations/createTournament": typeof _model_tournaments_mutations_createTournament; "_model/tournaments/mutations/deleteTournament": typeof _model_tournaments_mutations_deleteTournament; "_model/tournaments/mutations/endTournament": typeof _model_tournaments_mutations_endTournament; - "_model/tournaments/mutations/openTournamentRound": typeof _model_tournaments_mutations_openTournamentRound; + "_model/tournaments/mutations/endTournamentRound": typeof _model_tournaments_mutations_endTournamentRound; "_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/updateTournament": typeof _model_tournaments_mutations_updateTournament; "_model/tournaments/queries/getTournament": typeof _model_tournaments_queries_getTournament; "_model/tournaments/queries/getTournamentOpenRound": typeof _model_tournaments_queries_getTournamentOpenRound; diff --git a/convex/_model/fowV4/calculateFowV4MatchResultScore.ts b/convex/_model/fowV4/calculateFowV4MatchResultScore.ts index 74c2517c..21d695ba 100644 --- a/convex/_model/fowV4/calculateFowV4MatchResultScore.ts +++ b/convex/_model/fowV4/calculateFowV4MatchResultScore.ts @@ -1,4 +1,5 @@ import { Doc } from '../../_generated/dataModel'; +import { DeepMatchResult } from '../matchResults'; /** * Calculate the Victory Points (i.e. score) for a given match result. @@ -9,7 +10,7 @@ import { Doc } from '../../_generated/dataModel'; * @param matchResult - The match result to score * @returns - A tuple with the scores for player 0 and 1 respectively */ -export const calculateFowV4MatchResultScore = (matchResult: Doc<'matchResults'>): [number, number] => { +export const calculateFowV4MatchResultScore = (matchResult: Doc<'matchResults'> | DeepMatchResult): [number, number] => { // TODO: Add some guards in case matchResult is not FowV4 diff --git a/convex/_model/fowV4/extractFowV4MatchResultBaseStats.ts b/convex/_model/fowV4/extractFowV4MatchResultBaseStats.ts index 7b89c6cb..23e6f08a 100644 --- a/convex/_model/fowV4/extractFowV4MatchResultBaseStats.ts +++ b/convex/_model/fowV4/extractFowV4MatchResultBaseStats.ts @@ -1,4 +1,5 @@ import { Doc } from '../../_generated/dataModel'; +import { DeepMatchResult } from '../matchResults'; import { calculateFowV4MatchResultScore } from './calculateFowV4MatchResultScore'; import { FowV4BaseStats } from './types'; @@ -9,7 +10,7 @@ import { FowV4BaseStats } from './types'; * @returns */ -export const extractFowV4MatchResultBaseStats = (matchResult: Doc<'matchResults'>): [FowV4BaseStats, FowV4BaseStats] => { +export const extractFowV4MatchResultBaseStats = (matchResult: Doc<'matchResults'> | DeepMatchResult): [FowV4BaseStats, FowV4BaseStats] => { const score = calculateFowV4MatchResultScore(matchResult); return [ { diff --git a/convex/_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.ts b/convex/_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.ts new file mode 100644 index 00000000..3b31c6b8 --- /dev/null +++ b/convex/_model/matchResults/_helpers/checkMatchResultBattlePlanVisibility.ts @@ -0,0 +1,55 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; + +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getTournamentShallow } from '../../../_model/tournaments'; +import { deepenTournamentPairing } from '../../tournamentPairings'; + +/** + * Checks if a match result's battle plans should be visible or not. + * + * @param ctx - Convex query context + * @param matchResult - Raw match result document + * @returns True if the battle plans should be visible, false if not + */ +export const checkMatchResultBattlePlanVisibility = async ( + ctx: QueryCtx, + matchResult: Doc<'matchResults'>, +): Promise => { + const userId = await getAuthUserId(ctx); + + // If the match result doesn't belong to a tournament pairing, battle plans should be visible: + if (!matchResult?.tournamentPairingId) { + return true; + } + + const tournamentPairing = await ctx.db.get(matchResult.tournamentPairingId); + + // If the match result's pairing has gone missing, treat it the same as a single match: + if (!tournamentPairing) { + return true; + } + const deepTournamentPairing = await deepenTournamentPairing(ctx, tournamentPairing); + const tournament = await getTournamentShallow(ctx, deepTournamentPairing.tournamentId); + + // If the match result is not from an on-going tournament, battle plans should be visible: + if (tournament?.status !== 'active') { + return true; + } + + if (userId) { + + // If the requesting user is an organizer, battle plans should be visible: + if (tournament.organizerUserIds.includes(userId)) { + return true; + } + + // If the requesting user is a player within that pairing, battle plans should be visible: + if (deepTournamentPairing.playerUserIds.includes(userId)) { + return true; + } + } + + // Hide battle plans in all other cases: + return false; +}; diff --git a/convex/_model/matchResults/_helpers/deepenMatchResult.ts b/convex/_model/matchResults/_helpers/deepenMatchResult.ts index 1ef3a542..55cd026f 100644 --- a/convex/_model/matchResults/_helpers/deepenMatchResult.ts +++ b/convex/_model/matchResults/_helpers/deepenMatchResult.ts @@ -2,6 +2,7 @@ import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; import { getMission } from '../../fowV4/getMission'; import { getUser } from '../../users/queries/getUser'; +import { checkMatchResultBattlePlanVisibility } from './checkMatchResultBattlePlanVisibility'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -24,19 +25,27 @@ export const deepenMatchResult = async ( const player1User = matchResult?.player1UserId ? await getUser(ctx, { id: matchResult.player1UserId, }) : null; - const mission = getMission(matchResult.details.missionId); + + // Social const comments = await ctx.db.query('matchResultComments') .withIndex('by_match_result_id',((q) => q.eq('matchResultId', matchResult._id))) .collect(); const likes = await ctx.db.query('matchResultLikes') .withIndex('by_match_result_id',((q) => q.eq('matchResultId', matchResult._id))) .collect(); + + // Details + const mission = getMission(matchResult.details.missionId); + const battlePlansVisible = await checkMatchResultBattlePlanVisibility(ctx, matchResult); + return { ...matchResult, ...(player0User ? { player0User } : {}), ...(player1User ? { player1User } : {}), details: { ...matchResult.details, + player0BattlePlan: battlePlansVisible ? matchResult.details.player0BattlePlan : undefined, + player1BattlePlan: battlePlansVisible ? matchResult.details.player1BattlePlan : undefined, missionName: mission?.displayName, }, likedByUserIds: likes.map((like) => like.userId), diff --git a/convex/_model/matchResults/fields.ts b/convex/_model/matchResults/fields.ts index 158c423e..8f1cacd3 100644 --- a/convex/_model/matchResults/fields.ts +++ b/convex/_model/matchResults/fields.ts @@ -5,11 +5,7 @@ import { fowV4GameSystemConfig } from '../fowV4/fowV4GameSystemConfig'; import { fowV4MatchResultDetails } from '../fowV4/fowV4MatchResultDetails'; export const editableFields = { - // Tournament tournamentPairingId: v.optional(v.id('tournamentPairings')), - // Denormalized so that we can filter match results by tournament. - // The duplicate data is worth the efficiency in querying. - tournamentId: v.optional(v.id('tournaments')), // Players player0UserId: v.optional(v.id('users')), @@ -18,19 +14,21 @@ export const editableFields = { player1Placeholder: v.optional(v.string()), // General - playedAt: v.string(), + playedAt: v.union(v.string(), v.number()), details: v.union(fowV4MatchResultDetails), // Game System gameSystemConfig: v.union(fowV4GameSystemConfig), - // Denormalized so that we can filter tournaments by game system. - // The duplicate data is worth the efficiency in querying. gameSystemId: gameSystemId, photoIds: v.optional(v.array(v.id('photos'))), }; export const computedFields = { + // Denormalized so that we can filter match results by tournament. + // The duplicate data is worth the efficiency in querying. + // Calculated from tournamentPairingId. + tournamentId: v.optional(v.id('tournaments')), player0Confirmed: v.optional(v.boolean()), player1Confirmed: v.optional(v.boolean()), modifiedAt: v.optional(v.number()), diff --git a/convex/_model/matchResults/index.ts b/convex/_model/matchResults/index.ts index 104b93c1..d8a0a47c 100644 --- a/convex/_model/matchResults/index.ts +++ b/convex/_model/matchResults/index.ts @@ -25,6 +25,7 @@ export { } from './mutations/addPhotoToMatchResult'; export { createMatchResult, + type CreateMatchResultArgs, createMatchResultArgs, } from './mutations/createMatchResult'; export { diff --git a/convex/_model/matchResults/mutations/createMatchResult.ts b/convex/_model/matchResults/mutations/createMatchResult.ts index 35ff9f49..8967d91d 100644 --- a/convex/_model/matchResults/mutations/createMatchResult.ts +++ b/convex/_model/matchResults/mutations/createMatchResult.ts @@ -11,6 +11,8 @@ export const createMatchResultArgs = v.object({ ...editableFields, }); +export type CreateMatchResultArgs = Infer; + /** * Creates a new match result. * @@ -20,16 +22,21 @@ export const createMatchResultArgs = v.object({ */ export const createMatchResult = async ( ctx: MutationCtx, - args: Infer, + args: CreateMatchResultArgs, ): Promise> => { const userId = await checkAuth(ctx); + let tournamentId: Id<'tournaments'> | undefined = undefined; + // ---- CHECK ELIGIBILITY ---- if (args.tournamentPairingId) { // Perform tournament-based auth checks for matches with a tournament pairing: const tournamentPairing = await getTournamentPairingDeep(ctx, args.tournamentPairingId); const tournament = await getTournamentShallow(ctx, tournamentPairing.tournamentId); + // Add computed value: + tournamentId = tournament._id; + const isPlayerInPairing = tournamentPairing.playerUserIds.includes(userId); const isTournamentOrganizer = tournament.organizerUserIds.includes(userId); if (!isPlayerInPairing && !isTournamentOrganizer ) { @@ -63,6 +70,7 @@ export const createMatchResult = async ( // Create the match result: return await ctx.db.insert('matchResults', { ...args, + tournamentId, player0Confirmed: true, // TODO: Default to false, require users to approve player1Confirmed: true, // TODO: Default to false, require users to approve }); diff --git a/convex/_model/matchResults/queries/getMatchResults.ts b/convex/_model/matchResults/queries/getMatchResults.ts index 8bbb3e30..6381ab63 100644 --- a/convex/_model/matchResults/queries/getMatchResults.ts +++ b/convex/_model/matchResults/queries/getMatchResults.ts @@ -4,7 +4,7 @@ import { deepenMatchResult, DeepMatchResult } from '../_helpers/deepenMatchResul export const getMatchResults = async ( ctx: QueryCtx, ): Promise => { - const matchResults = await ctx.db.query('matchResults').collect(); + const matchResults = await ctx.db.query('matchResults').order('desc').collect(); return await Promise.all(matchResults.map( async (item) => await deepenMatchResult(ctx, item), )); diff --git a/convex/_model/matchResults/queries/getMatchResultsByTournament.ts b/convex/_model/matchResults/queries/getMatchResultsByTournament.ts index 2a70339a..c4231215 100644 --- a/convex/_model/matchResults/queries/getMatchResultsByTournament.ts +++ b/convex/_model/matchResults/queries/getMatchResultsByTournament.ts @@ -13,6 +13,7 @@ export const getMatchResultsByTournament = async ( ): Promise => { const matchResults = await ctx.db.query('matchResults') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + .order('desc') .collect(); return await Promise.all(matchResults.map( async (item) => await deepenMatchResult(ctx, item), diff --git a/convex/_model/matchResults/queries/getMatchResultsByTournamentPairing.ts b/convex/_model/matchResults/queries/getMatchResultsByTournamentPairing.ts index add689e3..2e0feaf9 100644 --- a/convex/_model/matchResults/queries/getMatchResultsByTournamentPairing.ts +++ b/convex/_model/matchResults/queries/getMatchResultsByTournamentPairing.ts @@ -13,6 +13,7 @@ export const getMatchResultsByTournamentPairing = async ( ): Promise => { const matchResults = await ctx.db.query('matchResults') .withIndex('by_tournament_pairing_id', (q) => q.eq('tournamentPairingId', args.tournamentPairingId)) + .order('desc') .collect(); return await Promise.all(matchResults.map( async (item) => await deepenMatchResult(ctx, item), diff --git a/convex/_model/matchResults/queries/getMatchResultsByTournamentRound.ts b/convex/_model/matchResults/queries/getMatchResultsByTournamentRound.ts index 73b7e073..c3b5b33e 100644 --- a/convex/_model/matchResults/queries/getMatchResultsByTournamentRound.ts +++ b/convex/_model/matchResults/queries/getMatchResultsByTournamentRound.ts @@ -18,6 +18,7 @@ export const getMatchResultsByTournamentRound = async ( .collect(); const matchResults = await ctx.db.query('matchResults') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + .order('desc') .collect(); const filteredMatchResults = matchResults.filter((result) => ( !!tournamentPairings.find((item) => item._id === result.tournamentPairingId) diff --git a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts index 1f3778ad..276858b6 100644 --- a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts @@ -1,4 +1,4 @@ -import { Doc } from '../../../_generated/dataModel'; +import { Doc, Id } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; import { LimitedUser } from '../../users/_helpers/redactUser'; import { getUser } from '../../users/queries/getUser'; @@ -17,6 +17,12 @@ import { getUser } from '../../users/queries/getUser'; export const deepenTournamentCompetitor = async ( ctx: QueryCtx, tournamentCompetitor: Doc<'tournamentCompetitors'>, + results?: { + playedTables: (number | null)[]; + opponentIds: Id<'tournamentCompetitors'>[]; + byeRounds: number[]; + rank: number; + }, ) => { const players = await Promise.all(tournamentCompetitor.players.map(async ({ active, userId }) => ({ active, @@ -29,6 +35,7 @@ export const deepenTournamentCompetitor = async ( return { ...tournamentCompetitor, + ...results, players: players.filter(playerHasUser), }; }; diff --git a/convex/_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.ts b/convex/_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.ts new file mode 100644 index 00000000..7049d5fd --- /dev/null +++ b/convex/_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.ts @@ -0,0 +1,20 @@ +import { DeepTournamentCompetitor } from './deepenTournamentCompetitor'; + +export const sortTournamentCompetitorsByName = ( + a: DeepTournamentCompetitor, + b: DeepTournamentCompetitor, +): number => { + const getSortValue = (competitor: DeepTournamentCompetitor): string => { + if (competitor.teamName) { + return competitor.teamName; + } + if (competitor.players[0]?.user.familyName) { + return competitor.players[0].user.familyName; + } + if (competitor.players[0]?.user.username) { + return competitor.players[0].user.username; + } + return ''; + }; + return getSortValue(a).localeCompare(getSortValue(b)); +}; diff --git a/convex/_model/tournamentCompetitors/mutations/updateTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/mutations/updateTournamentCompetitor.ts index c91a2292..a21eda45 100644 --- a/convex/_model/tournamentCompetitors/mutations/updateTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/mutations/updateTournamentCompetitor.ts @@ -34,6 +34,15 @@ export const updateTournamentCompetitor = async ( if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('CANNOT_MODIFY_ARCHIVED_TOURNAMENT')); } + // If a tournament is active, never allow existing players to be removed, as this will break tournament results/rankings: + if (tournament.status === 'active') { + const updatedUserIds = new Set(args.players.map((p) => p.userId)); + for (const player of tournamentCompetitor.players) { + if (!updatedUserIds.has(player.userId)) { + throw new ConvexError(getErrorMessage('CANNOT_REMOVE_PLAYER_FROM_ACTIVE_TOURNAMENT')); + } + } + } // ---- EXTENDED AUTH CHECK ---- /* These user IDs can make changes to this tournament competitor: diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts index 479846f8..3537d69c 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts @@ -1,5 +1,6 @@ import { QueryCtx } from '../../../_generated/server'; import { deepenTournamentCompetitor, DeepTournamentCompetitor } from '../_helpers/deepenTournamentCompetitor'; +import { sortTournamentCompetitorsByName } from '../_helpers/sortTournamentCompetitorsByName'; export const getTournamentCompetitors = async ( ctx: QueryCtx, @@ -8,19 +9,5 @@ export const getTournamentCompetitors = async ( const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map( async (item) => await deepenTournamentCompetitor(ctx, item), )); - return deepTournamentCompetitors.sort((a, b) => { - const getSortValue = (competitor: DeepTournamentCompetitor): string => { - if (competitor.teamName) { - return competitor.teamName; - } - if (competitor.players[0].user.familyName) { - return competitor.players[0].user.familyName; - } - if (competitor.players[0].user.username) { - return competitor.players[0].user.username; - } - return ''; - }; - return getSortValue(a).localeCompare(getSortValue(b)); - }); + return deepTournamentCompetitors.sort(sortTournamentCompetitorsByName); }; diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index de9087c9..1b17b134 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -1,10 +1,13 @@ import { Infer,v } from 'convex/values'; import { QueryCtx } from '../../../_generated/server'; +import { getTournamentRankings } from '../../tournaments'; import { deepenTournamentCompetitor,DeepTournamentCompetitor } from '../_helpers/deepenTournamentCompetitor'; +import { sortTournamentCompetitorsByName } from '../_helpers/sortTournamentCompetitorsByName'; export const getTournamentCompetitorsByTournamentArgs = v.object({ tournamentId: v.id('tournaments'), + includeRankings: v.optional(v.number()), }); export const getTournamentCompetitorsByTournament = async ( @@ -14,22 +17,13 @@ export const getTournamentCompetitorsByTournament = async ( const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map( - async (item) => await deepenTournamentCompetitor(ctx, item), - )); - return deepTournamentCompetitors.sort((a, b) => { - const getSortValue = (competitor: DeepTournamentCompetitor): string => { - if (competitor.teamName) { - return competitor.teamName; - } - if (competitor.players[0]?.user.familyName) { - return competitor.players[0].user.familyName; - } - if (competitor.players[0]?.user.username) { - return competitor.players[0].user.username; - } - return ''; - }; - return getSortValue(a).localeCompare(getSortValue(b)); - }); + const rankings = args.includeRankings !== undefined && args.includeRankings > -1 ? await getTournamentRankings(ctx, { + tournamentId: args.tournamentId, + round: args.includeRankings, + }) : undefined; + const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map(async (item) => { + const results = rankings?.competitors.find((c) => c.id === item._id); + return await deepenTournamentCompetitor(ctx, item, results ); + })); + return deepTournamentCompetitors.sort(sortTournamentCompetitorsByName); }; diff --git a/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts b/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts index 56a90783..1ed4b351 100644 --- a/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts +++ b/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts @@ -62,6 +62,7 @@ export const deepenTournamentPairing = async ( matchResultsProgress: { submitted: matchResults.length, required: competitorSize, + remaining: competitorSize - matchResults.length, }, }; }; diff --git a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts index 117dbcd4..743e0175 100644 --- a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts +++ b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts @@ -7,7 +7,7 @@ import { assignBye } from './assignBye'; /** * A tuple of TournamentCompetitorRanked's to be paired. */ -export type DraftTournamentPairing = [TournamentCompetitorRanked, TournamentCompetitorRanked | null]; +export type CompetitorPair = [TournamentCompetitorRanked, TournamentCompetitorRanked | null]; /** * Generates draft pairings for an array of ranked TournamentCompetitors. @@ -23,8 +23,8 @@ export type DraftTournamentPairing = [TournamentCompetitorRanked, TournamentComp export const generateDraftPairings = ( orderedCompetitors: TournamentCompetitorRanked[], allowRepeats: boolean = false, -): DraftTournamentPairing[] => { - const pairings: DraftTournamentPairing[] = []; +): CompetitorPair[] => { + const pairings: CompetitorPair[] = []; // Handle byes: const [byeCompetitor, restCompetitors]= assignBye(orderedCompetitors); @@ -52,7 +52,7 @@ export const generateDraftPairings = ( export const recursivePair = ( pool: TournamentCompetitorRanked[], allowRepeats: boolean, -): DraftTournamentPairing[] | null => { +): CompetitorPair[] | null => { if (pool.length === 0) { return []; // everyone paired } diff --git a/convex/_model/tournamentPairings/_helpers/generateTableAssignments.ts b/convex/_model/tournamentPairings/_helpers/generateTableAssignments.ts deleted file mode 100644 index ef6814d0..00000000 --- a/convex/_model/tournamentPairings/_helpers/generateTableAssignments.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Infer, v } from 'convex/values'; -import blossom from 'edmonds-blossom'; - -import { Id } from '../../../_generated/dataModel'; - -export const unassignedTournamentPairingFields = v.object({ - playedTables: v.array(v.union(v.number(), v.null())), - tournamentCompetitor0Id: v.id('tournamentCompetitors'), - tournamentCompetitor1Id: v.union(v.id('tournamentCompetitors'), v.null()), -}); - -export type UnassignedTournamentPairing = Infer; - -// TODO: DOCUMENTATION - -export type AssignedTournamentPairing = { - tournamentCompetitor0Id: Id<'tournamentCompetitors'>; - tournamentCompetitor1Id: Id<'tournamentCompetitors'> | null; - table: number | null; -}; - -export const generateTableAssignments = ( - draftPairings: { - playedTables: (number | null)[], - tournamentCompetitor0Id: Id<'tournamentCompetitors'>, - tournamentCompetitor1Id: Id<'tournamentCompetitors'> | null, - }[], - tableCount: number, -): AssignedTournamentPairing[] => { - const fullPairs = draftPairings.filter( - (p) => p.tournamentCompetitor0Id && p.tournamentCompetitor1Id, - ); - const partialPairs = draftPairings.filter( - (p) => !p.tournamentCompetitor0Id || !p.tournamentCompetitor1Id, - ); - - const tableIndices = Array.from({ length: tableCount }, (_, i) => i); - - // Create a bipartite graph between full pairs and tables. - // We minimize "repeats" by assigning higher weights to edges that go to "new" tables. - - const edges: [number, number, number][] = []; - const tableOffset = fullPairs.length; - - fullPairs.forEach((pair, i) => { - const playedTables = pair.playedTables; - tableIndices.forEach((table) => { - const hasPlayed = playedTables.includes(table); - const weight = hasPlayed ? 0 : 1; // Prefer unplayed tables (higher weight) - edges.push([i, tableOffset + table, weight]); - }); - }); - - const match = blossom(edges); - - const assignedPairs: AssignedTournamentPairing[] = []; - - const usedTables = new Set(); - - // Match results come back as an array of matched node index => node index. - const tableAssignments: Map = new Map(); - - match.forEach((matchedIdx, idx) => { - if (idx < tableOffset && matchedIdx >= tableOffset) { - const pairIndex = idx; - const table = matchedIdx - tableOffset; - tableAssignments.set(pairIndex, table); - usedTables.add(table); - } - }); - - // Process full pairs with assigned tables - fullPairs.forEach(({ tournamentCompetitor0Id, tournamentCompetitor1Id }, index) => { - const table = tableAssignments.get(index); - assignedPairs.push({ - tournamentCompetitor0Id, - tournamentCompetitor1Id, - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - table: table ?? (() => { - // If not matched, assign first available (even if it's a repeat) - for (let t = 0; t < tableCount; t++) { - if (!usedTables.has(t)) { - usedTables.add(t); - return t; - } - } - // All tables used, allow repeats - return index % tableCount; - })(), - }); - }); - - // Process partial pairs by assigning them to remaining tables - partialPairs.forEach(({ tournamentCompetitor0Id, tournamentCompetitor1Id }) => { - assignedPairs.push({ - tournamentCompetitor0Id, - tournamentCompetitor1Id, - table: null, // If they don't play, don't make future table assignments more difficult by counting this one - }); - }); - - return assignedPairs; -}; diff --git a/convex/_model/tournamentPairings/_helpers/sortCompetitorPairs.ts b/convex/_model/tournamentPairings/_helpers/sortCompetitorPairs.ts new file mode 100644 index 00000000..fbce9309 --- /dev/null +++ b/convex/_model/tournamentPairings/_helpers/sortCompetitorPairs.ts @@ -0,0 +1,16 @@ +import { CompetitorPair } from './generateDraftPairings'; + +export const sortCompetitorPairs = ( + a: CompetitorPair, + b: CompetitorPair, +): -1 | 0 | 1 => { + const aHasNull = a[0] === null || a[1] === null; + const bHasNull = b[0] === null || b[1] === null; + if (aHasNull && !bHasNull) { + return 1; + } + if (!aHasNull && bHasNull) { + return -1; + } + return 0; +}; diff --git a/convex/_model/tournamentPairings/_helpers/sortPairingsByTable.ts b/convex/_model/tournamentPairings/_helpers/sortPairingsByTable.ts new file mode 100644 index 00000000..ff6344b2 --- /dev/null +++ b/convex/_model/tournamentPairings/_helpers/sortPairingsByTable.ts @@ -0,0 +1,28 @@ +import { ShallowTournamentPairing } from '..'; +import { DraftTournamentPairing } from '../queries/getDraftTournamentPairings'; +import { TournamentPairingDeep } from './deepenTournamentPairing'; + +type AnyPairing = TournamentPairingDeep | ShallowTournamentPairing | DraftTournamentPairing; + +export const sortPairingsByTable = ( + pairings: T[], +): T[] => pairings.sort((a, b) => { + const aTable = a.table; + const bTable = b.table; + + // Handle null or undefined table values + const aIsNull = aTable === null || aTable === undefined; + const bIsNull = bTable === null || bTable === undefined; + + if (aIsNull && bIsNull) { + return 0; + } + if (aIsNull) { + return 1; + } + if (bIsNull) { + return -1; + } + + return aTable - bTable; +}); diff --git a/convex/_model/tournamentPairings/index.ts b/convex/_model/tournamentPairings/index.ts index 377e8cfa..9930067d 100644 --- a/convex/_model/tournamentPairings/index.ts +++ b/convex/_model/tournamentPairings/index.ts @@ -1,6 +1,6 @@ import { defineTable } from 'convex/server'; -import { Id } from '../../_generated/dataModel'; +import { Doc, Id } from '../../_generated/dataModel'; import { computedFields, editableFields } from './fields'; export const tournamentPairingsTable = defineTable({ @@ -10,21 +10,14 @@ export const tournamentPairingsTable = defineTable({ .index('by_tournament_id', ['tournamentId']); export type TournamentPairingId = Id<'tournamentPairings'>; +export type ShallowTournamentPairing = Doc<'tournamentPairings'>; // Helpers export { deepenTournamentPairing, type TournamentPairingDeep, } from './_helpers/deepenTournamentPairing'; -export { - type DraftTournamentPairing, - generateDraftPairings, -} from './_helpers/generateDraftPairings'; -export { - generateTableAssignments, - type UnassignedTournamentPairing, - unassignedTournamentPairingFields, -} from './_helpers/generateTableAssignments'; +export { generateDraftPairings } from './_helpers/generateDraftPairings'; export { getTournamentPairingDeep } from './_helpers/getTournamentPairingDeep'; export { getTournamentPairingShallow } from './_helpers/getTournamentPairingShallow'; export { shuffle } from './_helpers/shuffle'; @@ -36,6 +29,7 @@ export { getActiveTournamentPairingsByUserArgs, } from './queries/getActiveTournamentPairingsByUser'; export { + type DraftTournamentPairing, getDraftTournamentPairings, getDraftTournamentPairingsArgs, } from './queries/getDraftTournamentPairings'; @@ -51,3 +45,9 @@ export { getTournamentPairingsByTournament, getTournamentPairingsByTournamentArgs, } from './queries/getTournamentPairingsByTournament'; + +// Mutations +export { + createTournamentPairings, + createTournamentPairingsArgs, +} from './mutations/createTournamentPairings'; diff --git a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts new file mode 100644 index 00000000..5c154a4b --- /dev/null +++ b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts @@ -0,0 +1,108 @@ +import { + ConvexError, + Infer, + v, +} from 'convex/values'; + +import { Id } from '../../../_generated/dataModel'; +import { MutationCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../../common/errors'; +import { getTournamentCompetitorsByTournament } from '../../tournamentCompetitors'; +import { getTournamentPairings } from '../../tournamentPairings'; +import { checkTournamentAuth, getTournamentShallow } from '../../tournaments'; +import { sharedFields, uniqueFields } from '../fields'; + +export const createTournamentPairingsArgs = v.object({ + ...sharedFields, + pairings: v.array(v.object({ + ...uniqueFields, + })), +}); + +export const createTournamentPairings = async ( + ctx: MutationCtx, + args: Infer, +): Promise[]> => { + const tournament = await getTournamentShallow(ctx, args.tournamentId); + const competitors = await getTournamentCompetitorsByTournament(ctx, { + tournamentId: args.tournamentId, + }); + const existingPairings = await getTournamentPairings(ctx, { + tournamentId: args.tournamentId, + round: args.round, + }); + + // --- CHECK AUTH ---- + checkTournamentAuth(ctx, tournament); + + // ---- VALIDATE ---- + if (tournament.status === 'draft') { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT')); + } + if (tournament.status === 'published') { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT')); + } + if (tournament.status === 'archived') { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT')); + } + if (tournament.currentRound !== undefined) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_IN_PROGRESS_ROUND')); + } + if (existingPairings.length) { + throw new ConvexError(getErrorMessage('TOURNAMENT_ALREADY_HAS_PAIRINGS_FOR_ROUND')); + } + if (!args.pairings.length) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_EMPTY_PAIRINGS_LIST')); + } + if (args.pairings.length > Math.ceil(tournament.maxCompetitors / 2)) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_TOO_MANY_PAIRINGS')); + } + + const pairingIds: Id<'tournamentPairings'>[] = []; + const pairedCompetitorIds = new Set>(); + + for (const pairing of args.pairings) { + + // ---- VALIDATE EACH PAIRING ---- + for (const id of [pairing.tournamentCompetitor0Id, pairing.tournamentCompetitor1Id]) { + const competitor = competitors.find((c) => c._id === id); + const activePlayers = (competitor?.players ?? []).filter((p) => p.active); + if (id !== null) { + 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(pairing.tournamentCompetitor0Id)) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_ALREADY_PAIRED_COMPETITOR')); + } + if (activePlayers.length < tournament.competitorSize) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_UNDER_STRENGTH_COMPETITOR')); + } + if (activePlayers.length > tournament.competitorSize) { + throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_OVER_STRENGTH_COMPETITOR')); + } + } + } + + // ---- PRIMARY ACTIONS ---- + const id = await ctx.db.insert('tournamentPairings', { + ...pairing, + tournamentId: args.tournamentId, + round: args.round, + }); + + // ---- TRACK RESULTS ---- + pairingIds.push(id); + pairedCompetitorIds.add(pairing.tournamentCompetitor0Id); + if (pairing.tournamentCompetitor1Id) { + pairedCompetitorIds.add(pairing.tournamentCompetitor1Id); + } + } + + // TODO: Throw error if tables are double-assigned + // TODO: Throw error if competitors are double-assigned + + return pairingIds; +}; diff --git a/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts b/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts index 7ef4aa75..43aa1358 100644 --- a/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts +++ b/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts @@ -3,11 +3,15 @@ import { Infer, v } from 'convex/values'; import { QueryCtx } from '../../../_generated/server'; import { tournamentPairingMethod } from '../../../static/tournamentPairingMethods'; import { getTournamentCompetitorsByTournament } from '../../tournamentCompetitors'; -import { getTournamentRankings } from '../../tournaments'; +import { getTournamentRankings, TournamentCompetitorRanked } from '../../tournaments'; import { generateDraftPairings } from '../_helpers/generateDraftPairings'; -import { DraftTournamentPairing } from '../_helpers/generateDraftPairings'; import { shuffle } from '../_helpers/shuffle'; import { sortByRank } from '../_helpers/sortByRank'; +import { sortCompetitorPairs } from '../_helpers/sortCompetitorPairs'; +import { uniqueFields } from '../fields'; + +const draftTournamentPairing = v.object(uniqueFields); +export type DraftTournamentPairing = Infer; export const getDraftTournamentPairingsArgs = v.object({ method: tournamentPairingMethod, @@ -34,14 +38,21 @@ export const getDraftTournamentPairings = async ( tournamentId: args.tournamentId, round: args.round - 1, // Get rankings for previous round }); - const activeCompetitors = rankedCompetitors.filter(({ id }) => !!competitors.find((c) => c._id === id && c.active)); + const activeCompetitors = rankedCompetitors.filter(({ id }) => ( + !!competitors.find((c) => c._id === id && c.active) + )); + const orderedCompetitors: TournamentCompetitorRanked[] = []; if (args.method === 'adjacent') { - const orderedCompetitors = sortByRank(activeCompetitors); - return generateDraftPairings(orderedCompetitors); + orderedCompetitors.push(...sortByRank(activeCompetitors)); } if (args.method === 'random') { - const orderedCompetitors = shuffle(activeCompetitors); - return generateDraftPairings(orderedCompetitors); + orderedCompetitors.push(...shuffle(activeCompetitors)); } - return []; + return generateDraftPairings(orderedCompetitors).sort(sortCompetitorPairs).map((draftPairing) => ({ + tournamentId: args.tournamentId, + tournamentCompetitor0Id: draftPairing[0].id, + tournamentCompetitor1Id: draftPairing[1]?.id ?? null, + table: -1, + round: args.round, + })); }; diff --git a/convex/_model/tournamentPairings/queries/getTournamentPairingsByTournament.ts b/convex/_model/tournamentPairings/queries/getTournamentPairingsByTournament.ts index aabfac3f..bda62241 100644 --- a/convex/_model/tournamentPairings/queries/getTournamentPairingsByTournament.ts +++ b/convex/_model/tournamentPairings/queries/getTournamentPairingsByTournament.ts @@ -26,5 +26,13 @@ export const getTournamentPairingsByTournament = async ( const deepTournamentPairings = await Promise.all( tournamentPairings.map(async (tournamentPairing) => await deepenTournamentPairing(ctx, tournamentPairing)), ); - return deepTournamentPairings.filter(notNullOrUndefined); + return deepTournamentPairings.filter(notNullOrUndefined).sort((a, b) => { + if (a.table === null) { + return 1; + } + if (b.table === null) { + return -1; + } + return a.table - b.table; + }); }; diff --git a/convex/_model/tournamentTimers/mutations/createTournamentTimer.ts b/convex/_model/tournamentTimers/mutations/createTournamentTimer.ts index 7c2b2c84..0d03c0ee 100644 --- a/convex/_model/tournamentTimers/mutations/createTournamentTimer.ts +++ b/convex/_model/tournamentTimers/mutations/createTournamentTimer.ts @@ -36,8 +36,8 @@ export const createTournamentTimer = async ( } return await ctx.db.insert('tournamentTimers', { ...args, - pausedAt: Date.now(), + pausedAt: null, pauseTime: 0, - startedAt: null, + startedAt: Date.now(), }); }; diff --git a/convex/_model/tournaments/README.md b/convex/_model/tournaments/README.md index 4125ea7a..81a9526e 100644 --- a/convex/_model/tournaments/README.md +++ b/convex/_model/tournaments/README.md @@ -7,7 +7,7 @@ | `createTournament()` | Editing | `'draft'` | `unset` | | `publishTournament()` | Published | `'published'` | `unset` | | `startTournament()` | Round 1 Set-Up | `'active'` | `unset` | -| `openTournamentRound()` | Round 1 Play | `'active'` | `0` | -| `closeTournamentRound()` | Round 2 Set-Up | `'active'` | `unset` | -| `openTournamentRound()` | Round 2 Play | `'active'` | `1` | +| `startTournamentRound()` | Round 1 Play | `'active'` | `0` | +| `endTournamentRound()` | Round 2 Set-Up | `'active'` | `unset` | +| `startTournamentRound()` | Round 2 Play | `'active'` | `1` | | `endTournament()` | Archived | `'archived'` | `unset` | \ No newline at end of file diff --git a/convex/_model/tournaments/_helpers/deepenTournament.ts b/convex/_model/tournaments/_helpers/deepenTournament.ts index e58a7098..7096b60f 100644 --- a/convex/_model/tournaments/_helpers/deepenTournament.ts +++ b/convex/_model/tournaments/_helpers/deepenTournament.ts @@ -36,22 +36,20 @@ export const deepenTournament = async ( ], [] as Id<'users'>[]); // Computed properties (easy to do, but used so frequently, it's nice to include them by default) - const playerCount = playerUserIds.length; - const activePlayerCount = activePlayerUserIds.length; - const maxPlayers = tournament.maxCompetitors * tournament.competitorSize; - const useTeams = tournament.competitorSize > 1; + const nextRound = (tournament.currentRound ?? tournament.lastRound ?? -1) + 1; return { ...tournament, logoUrl, bannerUrl, competitorCount, - activePlayerCount, - playerCount, + activePlayerCount: activePlayerUserIds.length, + playerCount: playerUserIds.length, playerUserIds, activePlayerUserIds, - maxPlayers, - useTeams, + maxPlayers : tournament.maxCompetitors * tournament.competitorSize, + useTeams: tournament.competitorSize > 1, + nextRound: nextRound < tournament.roundCount ? nextRound : undefined, }; }; diff --git a/convex/_model/tournaments/index.ts b/convex/_model/tournaments/index.ts index 5493bd62..38281fe7 100644 --- a/convex/_model/tournaments/index.ts +++ b/convex/_model/tournaments/index.ts @@ -11,6 +11,17 @@ export const tournamentsTable = defineTable({ .index('by_status', ['status']); export type TournamentId = Id<'tournaments'>; +export enum TournamentActionKey { + Edit = 'edit', + Delete = 'delete', + Publish = 'publish', + Cancel = 'cancel', + Start = 'start', + ConfigureRound = 'configureRound', + StartRound = 'startRound', + EndRound = 'endRound', + End = 'end', +} // Helpers export { checkTournamentAuth } from './_helpers/checkTournamentAuth'; @@ -20,10 +31,6 @@ export { getTournamentShallow } from './_helpers/getTournamentShallow'; export { getTournamentUserIds } from './_helpers/getTournamentUserIds'; // Mutations -export { - closeTournamentRound, - closeTournamentRoundArgs, -} from './mutations/closeTournamentRound'; export { createTournament, createTournamentArgs, @@ -37,9 +44,9 @@ export { endTournamentArgs, } from './mutations/endTournament'; export { - openTournamentRound, - openTournamentRoundArgs, -} from './mutations/openTournamentRound'; + endTournamentRound, + endTournamentRoundArgs, +} from './mutations/endTournamentRound'; export { publishTournament, publishTournamentArgs, @@ -48,6 +55,10 @@ export { startTournament, startTournamentArgs, } from './mutations/startTournament'; +export { + startTournamentRound, + startTournamentRoundArgs, +} from './mutations/startTournamentRound'; export { updateTournament, updateTournamentArgs, diff --git a/convex/_model/tournaments/mutations/endTournament.ts b/convex/_model/tournaments/mutations/endTournament.ts index 54feafad..b529ec6f 100644 --- a/convex/_model/tournaments/mutations/endTournament.ts +++ b/convex/_model/tournaments/mutations/endTournament.ts @@ -6,7 +6,6 @@ import { import { MutationCtx } from '../../../_generated/server'; import { getErrorMessage } from '../../../common/errors'; -import { deleteTournamentTimerByTournament } from '../../tournamentTimers'; import { checkTournamentAuth } from '../_helpers/checkTournamentAuth'; export const endTournamentArgs = v.object({ @@ -45,14 +44,11 @@ export const endTournament = async ( if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('TOURNAMENT_ALREADY_ARCHIVED')); } + if (tournament.currentRound !== undefined) { + throw new ConvexError(getErrorMessage('CANNOT_END_TOURNAMENT_MID_ROUND')); + } // ---- PRIMARY ACTIONS ---- - // Clean up TournamentTimer: - await deleteTournamentTimerByTournament(ctx, { - tournamentId: tournament._id, - round: tournament.currentRound, - }); - // End the tournament: await ctx.db.patch(args.id, { status: 'archived', diff --git a/convex/_model/tournaments/mutations/closeTournamentRound.ts b/convex/_model/tournaments/mutations/endTournamentRound.ts similarity index 87% rename from convex/_model/tournaments/mutations/closeTournamentRound.ts rename to convex/_model/tournaments/mutations/endTournamentRound.ts index ef9f2bc5..95364505 100644 --- a/convex/_model/tournaments/mutations/closeTournamentRound.ts +++ b/convex/_model/tournaments/mutations/endTournamentRound.ts @@ -10,20 +10,20 @@ import { deleteTournamentTimerByTournament } from '../../tournamentTimers'; import { checkTournamentAuth } from '../_helpers/checkTournamentAuth'; import { getTournamentShallow } from '../_helpers/getTournamentShallow'; -export const closeTournamentRoundArgs = v.object({ +export const endTournamentRoundArgs = v.object({ id: v.id('tournaments'), }); /** - * Closes a currently open Tournament round. + * Ends a currently open tournament round. * * @param ctx - Convex mutation context * @param args - Convex mutation args - * @param args.id - ID of the Tournament + * @param args.id - ID of the tournament */ -export const closeTournamentRound = async ( +export const endTournamentRound = async ( ctx: MutationCtx, - args: Infer, + args: Infer, ): Promise => { const tournament = await getTournamentShallow(ctx, args.id); diff --git a/convex/_model/tournaments/mutations/openTournamentRound.ts b/convex/_model/tournaments/mutations/startTournamentRound.ts similarity index 53% rename from convex/_model/tournaments/mutations/openTournamentRound.ts rename to convex/_model/tournaments/mutations/startTournamentRound.ts index b9ba3bcb..d4f28697 100644 --- a/convex/_model/tournaments/mutations/openTournamentRound.ts +++ b/convex/_model/tournaments/mutations/startTournamentRound.ts @@ -6,27 +6,24 @@ import { import { MutationCtx } from '../../../_generated/server'; import { getErrorMessage } from '../../../common/errors'; -import { generateTableAssignments, unassignedTournamentPairingFields } from '../../tournamentPairings'; import { createTournamentTimer } from '../../tournamentTimers'; import { checkTournamentAuth } from '../_helpers/checkTournamentAuth'; import { getTournamentShallow } from '../_helpers/getTournamentShallow'; -export const openTournamentRoundArgs = v.object({ +export const startTournamentRoundArgs = v.object({ id: v.id('tournaments'), - unassignedPairings: v.array(unassignedTournamentPairingFields), }); /** - * Finalizes draft TournamentPairings and opens a new Tournament round. + * Starts a new tournament round. * * @param ctx - Convex query context * @param args - Convex query args - * @param args.id - ID of the Tournament - * @param args.unassignedPairings - Draft TournamentPairings to assign to tables + * @param args.id - ID of the tournament */ -export const openTournamentRound = async ( +export const startTournamentRound = async ( ctx: MutationCtx, - args: Infer, + args: Infer, ): Promise => { const tournament = await getTournamentShallow(ctx, args.id); @@ -47,30 +44,10 @@ export const openTournamentRound = async ( throw new ConvexError(getErrorMessage('TOURNAMENT_ALREADY_HAS_OPEN_ROUND')); } - // TODO: Throw error if missing pairings - // TODO: Throw error if pairings are invalid - // TODO: Throw error if there are too many pairings or some competitors are not active, etc. - // TODO: Throw error if pairings for that round already exist - // TODO: Throw error if competitors have the wrong number of (active) players - // ---- PRIMARY ACTIONS ---- - const tableCount = Math.ceil(tournament.maxCompetitors / 2); const nextRound = (tournament.lastRound ?? -1) + 1; - // Assign pairings to tables: - const assignedPairings = generateTableAssignments(args.unassignedPairings, tableCount); - - // Create pairing records: - Promise.all(assignedPairings.map(async (pairing) => ( - // TODO: Make a mutation? - await ctx.db.insert('tournamentPairings', { - ...pairing, - round: nextRound, - tournamentId: args.id, - }) - ))); - - // Create a timer for the upcoming round: + // Create (and start) a timer for the upcoming round: await createTournamentTimer(ctx, { tournamentId: tournament._id, round: nextRound, diff --git a/convex/_model/tournaments/queries/getTournamentOpenRound.ts b/convex/_model/tournaments/queries/getTournamentOpenRound.ts index 1623bc72..01c86aad 100644 --- a/convex/_model/tournaments/queries/getTournamentOpenRound.ts +++ b/convex/_model/tournaments/queries/getTournamentOpenRound.ts @@ -28,26 +28,26 @@ export const getTournamentOpenRound = async ( if (tournament.status !== 'active' || tournament.currentRound === undefined) { return null; } - - const pairings = await ctx.db.query('tournamentPairings') + const tournamentPairings = await ctx.db.query('tournamentPairings') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.id)) .filter((q) => q.eq(q.field('round'), tournament.currentRound)) .collect(); - const matchResults = await ctx.db.query('matchResults') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.id)) + .order('desc') .collect(); - - const relevantPairingIds = pairings.map((pairing) => pairing._id); - const relevantMatchResultIds = matchResults.filter((matchResult) => ( - matchResult.tournamentPairingId && relevantPairingIds.includes(matchResult.tournamentPairingId) - )).map((matchResult) => matchResult._id); + const filteredMatchResults = matchResults.filter((result) => ( + !!tournamentPairings.find((item) => item._id === result.tournamentPairingId) + )); + const required = tournamentPairings.length * tournament.competitorSize; + const submitted = filteredMatchResults.length; return { round: tournament.currentRound, matchResultsProgress: { - submitted: relevantPairingIds.length * tournament.competitorSize, - required: relevantMatchResultIds.length, + required, + submitted, + remaining: Math.max(0, required - submitted), }, // TODO: Get timer }; diff --git a/convex/_model/utils/createTestTournament.ts b/convex/_model/utils/createTestTournament.ts index 57c011cd..1c9c805f 100644 --- a/convex/_model/utils/createTestTournament.ts +++ b/convex/_model/utils/createTestTournament.ts @@ -60,7 +60,7 @@ export const createTestTournament = async ( playingTime: 3, }, logoStorageId: 'kg208wxmb55v36bh9qnkqc0c397j1rmb' as Id<'_storage'>, - bannerStorageId: 'kg21scbttz4t1hxxcs9qjcsvkh7j0nkh' as Id<'_storage'>, + bannerStorageId: 'kg250q9ezj209wpxgn0xqfca297kckc8' as Id<'_storage'>, }); // 3. Create competitors diff --git a/convex/_model/utils/createTestTournamentMatchResults.ts b/convex/_model/utils/createTestTournamentMatchResults.ts index f84d9b6e..c1c5b3fb 100644 --- a/convex/_model/utils/createTestTournamentMatchResults.ts +++ b/convex/_model/utils/createTestTournamentMatchResults.ts @@ -1,5 +1,7 @@ import { Infer, v } from 'convex/values'; +import { createMockFowV4MatchResultData } from '../../_fixtures/fowV4/createMockFowV4MatchResultData'; +import { Doc, Id } from '../../_generated/dataModel'; import { MutationCtx } from '../../_generated/server'; export const createTestTournamentMatchResultsArgs = v.object({ @@ -28,38 +30,68 @@ export const createTestTournamentMatchResults = async ( throw new Error('No pairings to create results for!'); } tournamentPairings.forEach(async (pairing) => { - const tournamentCompetitor0 = await ctx.db.get(pairing.tournamentCompetitor0Id); - const tournamentCompetitor1 = pairing.tournamentCompetitor1Id ? await ctx.db.get(pairing.tournamentCompetitor1Id) : null; - if (!tournamentCompetitor0) { - throw new Error('Pairing needs at least 1 competitor!'); - } - const tournamentCompetitor0UserIds = tournamentCompetitor0.players.filter((player) => player.active).map((player) => player.userId); - const tournamentCompetitor1UserIds = tournamentCompetitor1 ? tournamentCompetitor1.players.filter((player) => player.active).map((player) => player.userId) : []; - for (let i = 0; i < tournament.competitorSize; i++) { - const outcomeType = Math.random() > 0.25 ? 'objective_taken' : 'time_out'; - await ctx.db.insert('matchResults', { + const existingMatchResults = await ctx.db.query('matchResults') + .withIndex('by_tournament_pairing_id', (q) => q.eq('tournamentPairingId', pairing._id)) + .collect(); + + const matchResultIds = existingMatchResults.map((matchResult) => matchResult._id); + const usedPlayerIds = existingMatchResults.reduce((acc, result) => { + if (result.player0UserId) { + acc.push(result.player0UserId); + } + if (result.player1UserId) { + acc.push(result.player1UserId); + } + return acc; + }, [] as Id<'users'>[]); + + let i = 0; + while (matchResultIds.length < tournament.competitorSize) { + i += 1; + + if (i > tournament.competitorSize * 2) { + throw new Error('Adding way too many match results! Something is wrong!'); + } + + const playerData: Pick, 'player0UserId' | 'player1UserId' | 'player1Placeholder' | 'player0Placeholder'> = {}; + const tournamentCompetitor0 = await ctx.db.get(pairing.tournamentCompetitor0Id); + const tournamentCompetitor0UserIds = tournamentCompetitor0 ? tournamentCompetitor0.players.filter((player) => ( + player.active && !usedPlayerIds.includes(player.userId) + )).map((player) => player.userId) : []; + const player0UserId = tournamentCompetitor0UserIds.pop(); + if (player0UserId) { + playerData.player0UserId = player0UserId; + } else { + playerData.player0Placeholder = 'Bye'; + } + + if (pairing.tournamentCompetitor1Id) { + const tournamentCompetitor1 = await ctx.db.get(pairing.tournamentCompetitor1Id); + const tournamentCompetitor1UserIds = tournamentCompetitor1 ? tournamentCompetitor1.players.filter((player) => ( + player.active && !usedPlayerIds.includes(player.userId) + )).map((player) => player.userId) : []; + const player1UserId = tournamentCompetitor1UserIds.pop(); + playerData.player1UserId = player1UserId; + } else { + playerData.player1Placeholder = 'Bye'; + } + + const matchResultId = await ctx.db.insert('matchResults', createMockFowV4MatchResultData({ + ...playerData, tournamentPairingId: pairing._id, - tournamentId: tournament._id, - player0UserId: tournamentCompetitor0UserIds[i], - player1UserId: tournamentCompetitor1UserIds[i], - player0Confirmed: true, - player1Confirmed: true, - playedAt: new Date().toISOString(), - details: { - attacker: 0, - firstTurn: 0, - missionId: 'flames_of_war_v4::mission::2023_04_spearpoint', - outcomeType, - player0BattlePlan: 'attack', - player0UnitsLost: Math.round(Math.random() * 5) + 2, - player1BattlePlan: 'attack', - player1UnitsLost: Math.round(Math.random() * 5) + 2, - turnsPlayed: Math.round(Math.random() * 5) + 2, - winner: outcomeType === 'time_out' ? -1 : (Math.random() > 0.5 ? 1 : 0), - }, gameSystemConfig: tournament.gameSystemConfig, gameSystemId: tournament.gameSystemId, - }); + })); + + if (matchResultId) { + matchResultIds.push(matchResultId); + if (playerData.player0UserId) { + usedPlayerIds.push(playerData.player0UserId); + } + if (playerData.player1UserId) { + usedPlayerIds.push(playerData.player1UserId); + } + } } }); }; diff --git a/convex/common/errors.ts b/convex/common/errors.ts index 90a52f21..de72e21f 100644 --- a/convex/common/errors.ts +++ b/convex/common/errors.ts @@ -10,14 +10,10 @@ export const errors = { CANNOT_MODIFY_ARCHIVED_TOURNAMENT: 'Cannot modify an archived tournament.', CANNOT_MODIFY_PUBLISHED_TOURNAMENT_COMPETITORS: 'Cannot modify competitor format for a published tournament.', CANNOT_REMOVE_ANOTHER_PLAYER: 'Cannot remove another player.', - CANNOT_REMOVE_COMPETITOR_FROM_ACTIVE_TOURNAMENT: 'Cannot add a competitor to an on-going tournament.', + CANNOT_REMOVE_COMPETITOR_FROM_ACTIVE_TOURNAMENT: 'Cannot remove a competitor from an on-going tournament.', + CANNOT_REMOVE_PLAYER_FROM_ACTIVE_TOURNAMENT: 'Cannot remove a player from an on-going tournament.', CANNOT_MODIFY_ANOTHER_TOURNAMENT_COMPETITOR: 'Cannot modify another tournament competitor.', - // Tournament (specific) - CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT: 'Cannot add pairings to an archived tournament.', - CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT: 'Cannot add pairings to a draft tournament.', - CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT: 'Cannot add pairings to a tournament that hasn\'t started yet.', - // Tournament Lifecycle CANNOT_CLOSE_ROUND_ON_ARCHIVED_TOURNAMENT: 'Cannot close a round on an archived tournament.', CANNOT_CLOSE_ROUND_ON_DRAFT_TOURNAMENT: 'Cannot close a round on a tournament which is still a draft.', @@ -34,6 +30,7 @@ export const errors = { CANNOT_PUBLISH_ACTIVE_TOURNAMENT: 'Cannot publish a tournament which is already active.', CANNOT_END_PUBLISHED_TOURNAMENT: 'Cannot end a tournament which has not started.', CANNOT_END_DRAFT_TOURNAMENT: 'Cannot end a tournament which is still a draft.', + CANNOT_END_TOURNAMENT_MID_ROUND: 'Cannot end a tournament which mid-round.', TOURNAMENT_ALREADY_HAS_OPEN_ROUND: 'Tournament already has an open round.', TOURNAMENT_DOES_NOT_HAVE_OPEN_ROUND: 'Tournament does not have a currently open round.', @@ -48,8 +45,6 @@ export const errors = { TOURNAMENT_TIMER_ALREADY_PAUSED: 'Tournament timer is already paused.', TOURNAMENT_TIMER_ALREADY_RUNNING: 'Tournament timer is already running.', TOURNAMENT_TIMER_ALREADY_EXISTS: 'Tournament already has a timer for this round.', - CANNOT_SUBSTITUTE_ONLY_ONE_PLAYER: 'Cannot substitute only one player.', - INVALID_PAIRING_LENGTH: 'foo', // Missing docs FILE_NOT_FOUND: 'Could not find a file with that ID.', @@ -78,6 +73,18 @@ export const errors = { // Pairings NO_VALID_PAIRINGS_POSSIBLE: 'No valid pairing result possible.', NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_REPEAT: 'No valid pairing result possible without allowing a repeat.', + CANNOT_ADD_EMPTY_PAIRINGS_LIST: 'Cannot add an empty list of pairings.', + CANNOT_ADD_PAIRING_FOR_ALREADY_PAIRED_COMPETITOR: 'Cannot add pairing for competitor who is already paired.', + CANNOT_ADD_PAIRING_FOR_INACTIVE_COMPETITOR: 'Cannot add pairing for competitor which is not checked in.', + CANNOT_ADD_PAIRING_FOR_MISSING_COMPETITOR: 'Cannot add pairing for a competitor which does not exist.', + CANNOT_ADD_PAIRING_FOR_OVER_STRENGTH_COMPETITOR: 'Cannot add pairing for competitor which is over strength.', + CANNOT_ADD_PAIRING_FOR_UNDER_STRENGTH_COMPETITOR: 'Cannot add pairing for competitor which is under strength.', + CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT: 'Cannot add pairings to an archived tournament.', + CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT: 'Cannot add pairings to a draft tournament.', + CANNOT_ADD_PAIRINGS_TO_IN_PROGRESS_ROUND: 'Cannot add pairings while round is already in-progress.', + CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT: 'Cannot add pairings to a tournament that hasn\'t started yet.', + CANNOT_ADD_TOO_MANY_PAIRINGS: 'Cannot add more pairings than the tournament is set-up for.', + TOURNAMENT_ALREADY_HAS_PAIRINGS_FOR_ROUND: 'Tournament already has pairings for this round.', }; export function getErrorMessage(code: keyof typeof errors): { message: string, code: string } { diff --git a/convex/tournamentPairings.ts b/convex/tournamentPairings.ts index b5929185..0b034b41 100644 --- a/convex/tournamentPairings.ts +++ b/convex/tournamentPairings.ts @@ -1,4 +1,4 @@ -import { query } from './_generated/server'; +import { mutation, query } from './_generated/server'; import * as model from './_model/tournamentPairings'; export const getTournamentPairing = query({ @@ -20,3 +20,8 @@ export const getActiveTournamentPairingsByUser = query({ args: model.getActiveTournamentPairingsByUserArgs, handler: model.getActiveTournamentPairingsByUser, }); + +export const createTournamentPairings = mutation({ + args: model.createTournamentPairingsArgs, + handler: model.createTournamentPairings, +}); diff --git a/convex/tournaments.ts b/convex/tournaments.ts index 3f0bba91..2de224de 100644 --- a/convex/tournaments.ts +++ b/convex/tournaments.ts @@ -36,9 +36,9 @@ export const deleteTournament = mutation({ handler: model.deleteTournament, }); -export const closeTournamentRound = mutation({ - args: model.closeTournamentRoundArgs, - handler: model.closeTournamentRound, +export const endTournamentRound = mutation({ + args: model.endTournamentRoundArgs, + handler: model.endTournamentRound, }); export const endTournament = mutation({ @@ -46,9 +46,9 @@ export const endTournament = mutation({ handler: model.endTournament, }); -export const openTournamentRound = mutation({ - args: model.openTournamentRoundArgs, - handler: model.openTournamentRound, +export const startTournamentRound = mutation({ + args: model.startTournamentRoundArgs, + handler: model.startTournamentRound, }); export const publishTournament = mutation({ diff --git a/package-lock.json b/package-lock.json index 517d70a6..56300639 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,6 @@ "convex": "^1.19.2", "date-fns": "^3.6.0", "date-fns-tz": "^3.2.0", - "edmonds-blossom": "^1.0.0", "embla-carousel-react": "^8.5.2", "fast-deep-equal": "^3.1.3", "flag-icons": "^7.2.3", @@ -84,7 +83,6 @@ "@stylistic/stylelint-config": "^2.0.0", "@stylistic/stylelint-plugin": "^3.1.0", "@testing-library/react": "^16.2.0", - "@types/edmonds-blossom": "^1.0.4", "@types/image-blob-reduce": "^4.1.4", "@types/luxon": "^3.4.2", "@types/node": "^22.13.5", @@ -7779,13 +7777,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/edmonds-blossom": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/edmonds-blossom/-/edmonds-blossom-1.0.4.tgz", - "integrity": "sha512-fqvPg7o20+XDGsx6UrzKe9ZWidWy2GJxw9L4C0i/wwueEwwUbxmo7eDcrTAvj2568ZRYobauAJMYjCCNlQsYiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -9843,12 +9834,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/edmonds-blossom": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/edmonds-blossom/-/edmonds-blossom-1.0.0.tgz", - "integrity": "sha512-wz18RgLg21nW4afc80d080fZuAjiaePfSoHje56aOiH8mO6O5Mc/VAv7s8bCBJkxsks37e0cYTS0dNirsQ4/rg==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.150", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.150.tgz", diff --git a/package.json b/package.json index e9cc563f..d961a5d3 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "convex": "^1.19.2", "date-fns": "^3.6.0", "date-fns-tz": "^3.2.0", - "edmonds-blossom": "^1.0.0", "embla-carousel-react": "^8.5.2", "fast-deep-equal": "^3.1.3", "flag-icons": "^7.2.3", @@ -100,7 +99,6 @@ "@stylistic/stylelint-config": "^2.0.0", "@stylistic/stylelint-plugin": "^3.1.0", "@testing-library/react": "^16.2.0", - "@types/edmonds-blossom": "^1.0.4", "@types/image-blob-reduce": "^4.1.4", "@types/luxon": "^3.4.2", "@types/node": "^22.13.5", diff --git a/src/api.ts b/src/api.ts index 0647faf2..2193ecae 100644 --- a/src/api.ts +++ b/src/api.ts @@ -36,9 +36,9 @@ export { // Tournament Pairings export { + type ShallowTournamentPairing, type TournamentPairingDeep as TournamentPairing, type TournamentPairingId, - type UnassignedTournamentPairing, } from '../convex/_model/tournamentPairings'; // Tournament Timers @@ -52,6 +52,7 @@ export { // Tournaments export { type TournamentDeep as Tournament, + TournamentActionKey, type TournamentId, } from '../convex/_model/tournaments'; diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts b/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts index 98aacbbf..9ecefcd8 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts +++ b/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts @@ -1,9 +1,4 @@ import { useModal } from '~/modals'; +import { ConfirmationDialogData } from './ConfirmationDialog.types'; -type ConfirmationDialogData = { - title?: string; - description?: string; - onConfirm: () => void; -}; - -export const useConfirmationDialog = (id: string) => useModal(id); +export const useConfirmationDialog = (id?: string) => useModal(id); diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.module.scss b/src/components/ConfirmationDialog/ConfirmationDialog.module.scss index e7fd3634..7559bd0a 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.module.scss +++ b/src/components/ConfirmationDialog/ConfirmationDialog.module.scss @@ -5,54 +5,11 @@ @use "/src/style/variables"; .ConfirmationDialog { - &_WarningsList { - @include flex.column($gap: 1rem); + &_Body { + @include flex.column; - padding: 0 var(--container-padding-x); - } - - &_WarningBlurb { - @include text.ui; - @include corners.normal; - @include borders.warning; - - display: grid; - grid-template-areas: - "icon header" - ". body"; - grid-template-columns: 1rem 1fr; - grid-template-rows: auto auto; - row-gap: 0.25rem; - column-gap: 0.5rem; - - padding: 1rem; - - color: var(--text-color-warning); - - background-color: var(--card-bg-warning); - - &_Icon { - grid-area: icon; - width: 1rem; - height: 1rem; + &[data-padding="true"] { + padding: 0 var(--container-padding-x); } - - &_Header { - grid-area: header; - } - - &_Body { - @include flex.column($gap: 0.5rem); - - grid-area: body; - - p { - color: inherit; - } - } - } - - &_Children { - padding: 0 var(--container-padding-x); } } diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 248c9608..e75ae3dc 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,7 +1,6 @@ -import { ReactNode } from 'react'; import clsx from 'clsx'; -import { TriangleAlert } from 'lucide-react'; +import { ConfirmationDialogProps } from '~/components/ConfirmationDialog/ConfirmationDialog.types'; import { Button } from '~/components/generic/Button'; import { ControlledDialog, @@ -10,23 +9,10 @@ import { DialogHeader, } from '~/components/generic/Dialog'; import { ScrollArea } from '~/components/generic/ScrollArea'; -import { ElementIntent } from '~/types/componentLib'; import { useConfirmationDialog } from './ConfirmationDialog.hooks'; import styles from './ConfirmationDialog.module.scss'; -export interface ConfirmationDialogProps { - children?: ReactNode; - className?: string; - description?: string; - id: string; - intent?: ElementIntent; - onConfirm?: () => void; - title: string; - warnings?: ReactNode[]; - disabled?: boolean; -} - export const ConfirmationDialog = ({ children, className, @@ -35,8 +21,10 @@ export const ConfirmationDialog = ({ intent = 'default', onConfirm, title, - warnings = [], disabled = false, + disablePadding = false, + cancelLabel = 'Cancel', + confirmLabel = 'Confirm', }: ConfirmationDialogProps): JSX.Element => { const { close, data } = useConfirmationDialog(id); const handleConfirm = (): void => { @@ -50,40 +38,24 @@ export const ConfirmationDialog = ({ }; return ( - + {(data?.description || description) && ( {data?.description || description} )} - {warnings.length > 0 && ( -
- {warnings.map((warning, i) => ( -
- -

- Warning -

-
- {warning} -
-
- ))} -
- )} - {children && ( -
- {children} -
- )} +
+ {data?.children} + {children} +
diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.types.ts b/src/components/ConfirmationDialog/ConfirmationDialog.types.ts new file mode 100644 index 00000000..01fd77da --- /dev/null +++ b/src/components/ConfirmationDialog/ConfirmationDialog.types.ts @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; + +import { ElementIntent } from '~/types/componentLib'; + +export interface ConfirmationDialogProps { + children?: ReactNode; + className?: string; + description?: ReactNode; + id: string; + intent?: ElementIntent; + onConfirm?: () => void; + title?: string; + disabled?: boolean; + disablePadding?: boolean; + confirmLabel?: string; + cancelLabel?: string; +} + +export type ConfirmationDialogData = Partial; diff --git a/src/components/ConfirmationDialog/index.ts b/src/components/ConfirmationDialog/index.ts index 1fd5bb37..0fbd5f69 100644 --- a/src/components/ConfirmationDialog/index.ts +++ b/src/components/ConfirmationDialog/index.ts @@ -1,3 +1,6 @@ -export type { ConfirmationDialogProps } from './ConfirmationDialog'; export { ConfirmationDialog } from './ConfirmationDialog'; export { useConfirmationDialog } from './ConfirmationDialog.hooks'; +export { + type ConfirmationDialogData, + type ConfirmationDialogProps, +} from './ConfirmationDialog.types'; diff --git a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.utils.ts b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.utils.ts index 67c299fc..6116551a 100644 --- a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.utils.ts +++ b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.utils.ts @@ -12,10 +12,10 @@ export const formatOutcome = ( } if (details.winner !== -1 && details.outcomeType === 'force_broken') { if (details.winner === 0) { - return `${playerNames[0]} broke ${playerNames[1]}\u{2019}s formation(s).`; + return `${playerNames[0]} broke ${playerNames[1]}\u{2019}s formation.`; } if (details.winner === 1) { - return `${playerNames[1]} broke ${playerNames[0]}\u{2019}s formation(s).`; + return `${playerNames[1]} broke ${playerNames[0]}\u{2019}s formation.`; } } return 'Draw / Time Out'; diff --git a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.schema.ts b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.schema.ts index 62f608a9..6db6607f 100644 --- a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.schema.ts +++ b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.schema.ts @@ -42,7 +42,7 @@ export const fowV4MatchResultFormSchema = z.object({ // Non-editable gameSystemId: z.string().transform((val) => val as GameSystemId), - playedAt: z.string(), // TODO: not visible, enable later + playedAt: z.union([z.string(), z.number()]), // TODO: not visible, enable later }).superRefine((values, ctx) => { if (values.details.outcomeType !== 'time_out' && values.details.winner === undefined) { ctx.addIssue({ diff --git a/src/components/IdentityBadge/IdentityBadge.module.scss b/src/components/IdentityBadge/IdentityBadge.module.scss index 1d9e1536..4880289b 100644 --- a/src/components/IdentityBadge/IdentityBadge.module.scss +++ b/src/components/IdentityBadge/IdentityBadge.module.scss @@ -7,6 +7,10 @@ @include flex.row($gap: var(--avatar-spacing)); + &[data-flipped="true"] { + justify-content: flex-end; + } + &_Avatar { place-self: center center; width: var(--avatar-size); diff --git a/src/components/IdentityBadge/IdentityBadge.tsx b/src/components/IdentityBadge/IdentityBadge.tsx index b90a7392..41333e89 100644 --- a/src/components/IdentityBadge/IdentityBadge.tsx +++ b/src/components/IdentityBadge/IdentityBadge.tsx @@ -60,7 +60,7 @@ export const IdentityBadge = ({ // TODO: Add claim button ]; return ( -
+
{flipped ? elements.reverse() : elements} {/* TODO: Add factions */}
diff --git a/src/components/IdentityBadge/IdentityBadge.types.ts b/src/components/IdentityBadge/IdentityBadge.types.ts index db74fdaf..e7bacc00 100644 --- a/src/components/IdentityBadge/IdentityBadge.types.ts +++ b/src/components/IdentityBadge/IdentityBadge.types.ts @@ -22,7 +22,7 @@ export type Identity = { }; export type IdentityBadgePlaceholder = { - displayName: string; + displayName?: string; icon?: ReactElement; allowClaim?: boolean; }; diff --git a/src/components/MatchResultPlayers/MatchResultPlayers.module.scss b/src/components/MatchResultPlayers/MatchResultPlayers.module.scss index 7e32cacf..593f5083 100644 --- a/src/components/MatchResultPlayers/MatchResultPlayers.module.scss +++ b/src/components/MatchResultPlayers/MatchResultPlayers.module.scss @@ -40,8 +40,8 @@ &[data-orientation="horizontal"] { /* stylelint-disable-next-line @stylistic/max-line-length */ - grid-template-areas: "player0name player0avatar player0faction player0score separator player1score player1faction player1avatar player1name"; - grid-template-columns: 1fr 3rem 2rem 2rem variables.$border-width 2rem 2rem 3rem 1fr; + grid-template-areas: "player0identity player0faction player0score separator player1score player1faction player1identity"; + grid-template-columns: 1fr 2rem 2rem variables.$border-width 2rem 2rem 1fr; grid-template-rows: auto; gap: 1rem; @@ -55,16 +55,24 @@ &[data-orientation="vertical"] { grid-template-areas: - "player0avatar player0name player0faction player0score" - "separator separator separator ." - "player1avatar player1name player1faction player1score"; - grid-template-columns: 3rem 1fr 2rem 3rem; + "player0identity player0faction player0score" + "separator separator ." + "player1identity player1faction player1score"; + grid-template-columns: 1fr 2rem 2rem; grid-template-rows: auto variables.$border-width auto; row-gap: var(--vertical-spacing); column-gap: 1rem; } } +.Player0Identity { + grid-area: player0identity; +} + +.Player1Identity { + grid-area: player1identity; +} + .Player0Avatar { grid-area: player0avatar; } diff --git a/src/components/MatchResultPlayers/MatchResultPlayers.tsx b/src/components/MatchResultPlayers/MatchResultPlayers.tsx index 63021cbb..65a5af3f 100644 --- a/src/components/MatchResultPlayers/MatchResultPlayers.tsx +++ b/src/components/MatchResultPlayers/MatchResultPlayers.tsx @@ -1,9 +1,8 @@ import clsx from 'clsx'; -import { Avatar } from '~/components/generic/Avatar'; +import { IdentityBadge } from '~/components/IdentityBadge'; import { useMatchResult } from '~/components/MatchResultProvider'; import { useElementSize } from '~/hooks/useElementSize'; -import { getUserDisplayNameReact } from '~/utils/common/getUserDisplayNameReact'; import { calculateMatchScore } from '~/utils/flamesOfWarV4Utils/calculateMatchScore'; import styles from './MatchResultPlayers.module.scss'; @@ -19,20 +18,17 @@ export const MatchResultPlayers = ({ const [ref, width] = useElementSize(); const orientation = Math.ceil(width) < 640 ? 'vertical' : 'horizontal'; // 2 x 320 + 1rem - 2x border - const player0Name = matchResult.player0User ? getUserDisplayNameReact(matchResult.player0User) : matchResult.player0Placeholder; - const player1Name = matchResult.player1User ? getUserDisplayNameReact(matchResult.player1User) : matchResult.player1Placeholder; - const [player0Score, player1Score] = calculateMatchScore(matchResult.details); return (
- -
- {player0Name} -
{/* TODO: Add factions */} {/* @@ -50,13 +46,12 @@ export const MatchResultPlayers = ({ {player0Score}
- -
- {player1Name} -
{/* TODO: Add factions */} {/* diff --git a/src/components/PageWrapper/PageWrapper.tsx b/src/components/PageWrapper/PageWrapper.tsx index 0c2d7291..35f8b0e7 100644 --- a/src/components/PageWrapper/PageWrapper.tsx +++ b/src/components/PageWrapper/PageWrapper.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import clsx from 'clsx'; import { ArrowLeft } from 'lucide-react'; @@ -17,6 +17,7 @@ export interface PageWrapperProps { maxWidth?: number; showBackButton?: boolean; title?: string; + hideTitle?: boolean; banner?: ReactNode; bannerBackgroundUrl?: string; } @@ -28,11 +29,19 @@ export const PageWrapper = ({ maxWidth = MAX_WIDTH, showBackButton = false, title, + hideTitle = false, banner, bannerBackgroundUrl, }: PageWrapperProps): JSX.Element => { const navigate = useNavigate(); const { pathname } = useLocation(); + + useEffect(() => { + if (title?.length) { + document.title = `Combat Command | ${title}`; + } + }, [title]); + const handleClickBack = (): void => { if (window.history.length > 1) { navigate(-1); @@ -60,7 +69,7 @@ export const PageWrapper = ({
)}
- {(showBackButton || title) && ( + {(showBackButton || (title && !hideTitle)) && (
{showBackButton && (
+
+

{tournament.title}

+
+ {showContextMenu && ( + )} - - + +
+ +
- - -
+ ); }; diff --git a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts index 31563df4..ad33abaf 100644 --- a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts +++ b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts @@ -49,8 +49,8 @@ export const defaultValues: DeepPartial = { export const getDefaultValues = (competitorSize: number, existingCompetitor?: TournamentCompetitor): DeepPartial => ({ teamName: existingCompetitor?.teamName ?? '', - players: (existingCompetitor?.players || []).map(({ active, user }) => ({ - active, - userId: user._id, + players: Array.from({ length: competitorSize }).map((_, i) => ({ + active: existingCompetitor?.players[i]?.active ?? true, + userId: existingCompetitor?.players[i]?.user._id ?? '', })), }); diff --git a/src/components/TournamentContextMenu/TournamentContextMenu.tsx b/src/components/TournamentContextMenu/TournamentContextMenu.tsx index 3d868816..0dbe0c83 100644 --- a/src/components/TournamentContextMenu/TournamentContextMenu.tsx +++ b/src/components/TournamentContextMenu/TournamentContextMenu.tsx @@ -1,21 +1,8 @@ -import { generatePath, useNavigate } from 'react-router-dom'; import { Ellipsis } from 'lucide-react'; -import { useAuth } from '~/components/AuthProvider'; import { Button } from '~/components/generic/Button'; import { PopoverMenu } from '~/components/generic/PopoverMenu'; -import { toast } from '~/components/ToastProvider'; -import { getRemainingRequiredMatchResults } from '~/components/TournamentContextMenu/TournamentContextMenu.utils'; -import { useTournament } from '~/components/TournamentProvider'; -import { - useCloseTournamentRound, - useDeleteTournament, - useEndTournament, - useGetTournamentOpenRound, - usePublishTournament, - useStartTournament, -} from '~/services/tournaments'; -import { PATHS } from '~/settings'; +import { useTournamentActions } from '~/components/TournamentActionsProvider'; import { ElementSize } from '~/types/componentLib'; export interface TournamentContextMenuProps { @@ -29,95 +16,12 @@ export const TournamentContextMenu = ({ size = 'normal', variant = 'secondary', }: TournamentContextMenuProps): JSX.Element | null => { - const user = useAuth(); - const { - _id: id, - status, - currentRound, - lastRound, - title, - organizerUserIds, - } = useTournament(); - const navigate = useNavigate(); - const { data: openRound } = useGetTournamentOpenRound({ id }); - - const { mutation: deleteTournament } = useDeleteTournament({ - onSuccess: (): void => { - toast.success(`${title} deleted!`); - navigate(PATHS.tournaments); - }, - }); - - const { mutation: publishTournament } = usePublishTournament({ - onSuccess: (): void => { - toast.success(`${title} is now published!`); - }, - }); - - const { mutation: startTournament } = useStartTournament({ - onSuccess: (): void => { - toast.success(`${title} has started!`); - }, - }); - - const { mutation: endTournament } = useEndTournament({ - onSuccess: (): void => { - toast.success(`${title} completed!`); - }, - }); - - const { mutation: closeTournamentRound } = useCloseTournamentRound({ - onSuccess: (): void => { - toast.success(`Round ${(currentRound ?? 0) + 1} completed!`); - }, - }); - - const menuItems = [ - { - label: 'Edit', - onClick: () => navigate(generatePath(PATHS.tournamentEdit, { id })), - visible: status !== 'active' && status !== 'archived', - }, - { - label: 'Delete', - onClick: () => deleteTournament({ id }), - // TODO: Implement confirmation dialog - visible: status !== 'active' && status !== 'archived', - }, - { - label: 'Publish', - onClick: () => publishTournament({ id }), - // TODO: Implement confirmation dialog - visible: status === 'draft', - }, - { - label: 'Start', - onClick: () => startTournament({ id }), - visible: status === 'published', - }, - { - label: `Configure Round ${(lastRound ?? -1) + 2}`, - onClick: () => navigate(generatePath(PATHS.tournamentAdvanceRound, { id })), - visible: status === 'active' && currentRound === undefined, - }, - { - label: `Close Round ${currentRound! + 1}`, - onClick: () => closeTournamentRound({ id }), - visible: openRound && getRemainingRequiredMatchResults(openRound) === 0, - }, - { - label: 'End Tournament', - onClick: () => endTournament({ id }), - // TODO: More checks, show confirmation dialog if not complete - visible: status === 'active', - }, - ]; - - const visibleMenuItems = menuItems.filter((item) => item.visible); - - // TODO: Make better check for showing context menu - const showContextMenu = user && organizerUserIds.includes(user._id) && visibleMenuItems.length; - if (!showContextMenu) { + const actions = useTournamentActions(); + const visibleMenuItems = Object.values(actions).map(({ label, handler }) => ({ + label, + onClick: handler, + })); + if (!visibleMenuItems.length) { return null; } return ( diff --git a/src/components/TournamentContextMenu/TournamentContextMenu.utils.ts b/src/components/TournamentContextMenu/TournamentContextMenu.utils.ts deleted file mode 100644 index 821a2059..00000000 --- a/src/components/TournamentContextMenu/TournamentContextMenu.utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TournamentOpenRound } from '~/services/tournaments'; - -export const getRemainingRequiredMatchResults = ( - openRound?: TournamentOpenRound, -): number | null => { - if (!openRound) { - return null; - } - return openRound.matchResultsProgress.required - openRound.matchResultsProgress.submitted; -}; diff --git a/src/components/TournamentPairingRow/TournamentPairingRow.tsx b/src/components/TournamentPairingRow/TournamentPairingRow.tsx index ff9e5e23..0681a45a 100644 --- a/src/components/TournamentPairingRow/TournamentPairingRow.tsx +++ b/src/components/TournamentPairingRow/TournamentPairingRow.tsx @@ -1,11 +1,7 @@ import { useWindowWidth } from '@react-hook/window-size/throttled'; import clsx from 'clsx'; -import { - DraftTournamentPairing, - TournamentPairing, - UnassignedTournamentPairing, -} from '~/api'; +import { DraftTournamentPairing, TournamentPairing } from '~/api'; import { IdentityBadge } from '~/components/IdentityBadge'; import { MOBILE_BREAKPOINT } from '~/settings'; import { getIdentityBadgeProps } from './TournamentPairingRow.utils'; @@ -13,7 +9,7 @@ import { getIdentityBadgeProps } from './TournamentPairingRow.utils'; import styles from './TournamentPairingRow.module.scss'; export interface TournamentPairingRowProps { - pairing?: TournamentPairing | DraftTournamentPairing | UnassignedTournamentPairing; + pairing?: TournamentPairing | DraftTournamentPairing; loading?: boolean; className?: string; } diff --git a/src/components/TournamentPairingRow/TournamentPairingRow.utils.tsx b/src/components/TournamentPairingRow/TournamentPairingRow.utils.tsx index f4f9324f..9e60cfff 100644 --- a/src/components/TournamentPairingRow/TournamentPairingRow.utils.tsx +++ b/src/components/TournamentPairingRow/TournamentPairingRow.utils.tsx @@ -1,21 +1,10 @@ import { ChevronRight } from 'lucide-react'; -import { - DraftTournamentPairing, - TournamentPairing, - UnassignedTournamentPairing, -} from '~/api'; +import { DraftTournamentPairing, TournamentPairing } from '~/api'; import { IdentityBadgeProps } from '~/components/IdentityBadge'; +import { TournamentPairingFormItem } from '~/pages/TournamentPairingsPage/TournamentPairingsPage.schema'; -export function isDraftPairing(pairing: unknown): pairing is DraftTournamentPairing { - return Array.isArray(pairing) && - pairing.length > 0 && - typeof pairing[0] === 'object' && - pairing[0] !== null && - 'id' in pairing[0]; -} - -export function isUnassignedPairingInput(pairing: unknown): pairing is UnassignedTournamentPairing { +export function isUnassignedPairingInput(pairing: unknown): pairing is DraftTournamentPairing { return typeof pairing === 'object' && pairing !== null && 'tournamentCompetitor0Id' in pairing; @@ -28,21 +17,8 @@ export function isTournamentPairing(pairing: unknown): pairing is TournamentPair } export const getIdentityBadgeProps = ( - pairing?: TournamentPairing | DraftTournamentPairing | UnassignedTournamentPairing, + pairing?: TournamentPairing | TournamentPairingFormItem | DraftTournamentPairing, ): [Partial, Partial] => { - if (isDraftPairing(pairing)) { - if (pairing[1]) { - return [ - { competitorId: pairing[0].id }, - { competitorId: pairing[1].id }, - ]; - } - return [ - { competitorId: pairing[0].id }, - { placeholder: { displayName: 'Bye', icon: } }, - ]; - } - if (isUnassignedPairingInput(pairing)) { if (pairing.tournamentCompetitor1Id) { return [ diff --git a/src/components/TournamentPairingsGrid/Draggable/Draggable.module.scss b/src/components/TournamentPairingsGrid/Draggable/Draggable.module.scss deleted file mode 100644 index aa68f25c..00000000 --- a/src/components/TournamentPairingsGrid/Draggable/Draggable.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -.Draggable { - cursor: grab; - /* stylelint-disable-next-line property-no-vendor-prefix */ - -webkit-user-select: none; - user-select: none; - - outline: none; - - -webkit-touch-callout: none; -} diff --git a/src/components/TournamentPairingsGrid/Draggable/Draggable.tsx b/src/components/TournamentPairingsGrid/Draggable/Draggable.tsx deleted file mode 100644 index 935ebe9d..00000000 --- a/src/components/TournamentPairingsGrid/Draggable/Draggable.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ReactNode } from 'react'; -import { UniqueIdentifier, useDraggable } from '@dnd-kit/core'; -import { CSS } from '@dnd-kit/utilities'; - -import styles from './Draggable.module.scss'; - -export interface DraggableProps { - id: UniqueIdentifier; - children: ReactNode; - isOverlay?: boolean; -} - -export const Draggable = ({ - id, - children, -}: DraggableProps): JSX.Element => { - const { - attributes, - listeners, - setNodeRef, - transform, - isDragging, - } = useDraggable({ id }); - const style = { - transform: CSS.Translate.toString(transform), - opacity: isDragging ? 0 : 1, - // cursor: isDragging ? 'grabbing' : 'grab', - }; - return ( -
- {children} -
- ); -}; diff --git a/src/components/TournamentPairingsGrid/Draggable/index.ts b/src/components/TournamentPairingsGrid/Draggable/index.ts deleted file mode 100644 index 0b199a17..00000000 --- a/src/components/TournamentPairingsGrid/Draggable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Draggable } from './Draggable'; diff --git a/src/components/TournamentPairingsGrid/Droppable/Droppable.module.scss b/src/components/TournamentPairingsGrid/Droppable/Droppable.module.scss deleted file mode 100644 index 96142f44..00000000 --- a/src/components/TournamentPairingsGrid/Droppable/Droppable.module.scss +++ /dev/null @@ -1,43 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/variants"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -// TODO: Move to proper variables -@import "@radix-ui/colors/gray-alpha.css"; -@import "@radix-ui/colors/gray-dark.css"; -@import "@radix-ui/colors/tomato-alpha.css"; -@import "@radix-ui/colors/tomato-dark.css"; - -.Droppable { - @include corners.normal; - - // @include flex.stretchy; - - /* stylelint-disable-next-line property-no-vendor-prefix */ - -webkit-user-select: none; - user-select: none; - - padding: 0.25rem; - - background-color: var(--gray-a2); - outline: none; - - transition: background-color 250ms cubic-bezier(0.18, 0.67, 0.6, 1.22); - - -webkit-touch-callout: none; - - &[data-over="true"] { - background-color: var(--gray-a3); - } - - &[data-invalid="true"] { - background-color: var(--tomato-a4); - - &[data-over="true"] { - background-color: var(--tomato-a6); - } - } -} diff --git a/src/components/TournamentPairingsGrid/Droppable/Droppable.tsx b/src/components/TournamentPairingsGrid/Droppable/Droppable.tsx deleted file mode 100644 index 4a871aa1..00000000 --- a/src/components/TournamentPairingsGrid/Droppable/Droppable.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { CSSProperties } from 'react'; -import { UniqueIdentifier, useDroppable } from '@dnd-kit/core'; -import clsx from 'clsx'; - -import styles from './Droppable.module.scss'; - -export interface DroppableProps { - children: React.ReactNode; - className?: string; - id: UniqueIdentifier; - invalid?: boolean; - style?: CSSProperties; -} - -export const Droppable = ({ - children, - className, - id, - invalid, - style = {}, -}: DroppableProps): JSX.Element => { - const { isOver, setNodeRef } = useDroppable({ id }); - return ( -
- {children} -
- ); -}; diff --git a/src/components/TournamentPairingsGrid/Droppable/index.ts b/src/components/TournamentPairingsGrid/Droppable/index.ts deleted file mode 100644 index d9160b8a..00000000 --- a/src/components/TournamentPairingsGrid/Droppable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Droppable } from './Droppable'; diff --git a/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.module.scss b/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.module.scss deleted file mode 100644 index 770e23ce..00000000 --- a/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.module.scss +++ /dev/null @@ -1,36 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/variants"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.PairableCompetitorCard { - --rank-size: 2.5rem; - - @include variants.outlined($hover: false); - @include shadows.surface; - @include corners.normal; - - display: grid; - grid-template-areas: "identity rank"; - grid-template-columns: 1fr var(--rank-size); - padding: calc(0.75rem - 1px); - - &_Identity { - grid-area: identity; - } - - &_Rank { - @include text.ui; - @include flex.centered; - - grid-area: rank; - - width: var(--rank-size); - - font-size: 2rem; - font-weight: 300; - line-height: var(--rank-size); - } -} diff --git a/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.tsx b/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.tsx deleted file mode 100644 index e9221047..00000000 --- a/src/components/TournamentPairingsGrid/PairableCompetitorCard/PairableCompetitorCard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { - ComponentPropsWithoutRef, - ElementRef, - forwardRef, -} from 'react'; -import clsx from 'clsx'; -import { motion } from 'framer-motion'; - -import { TournamentCompetitorId } from '~/api'; -import { IdentityBadge } from '~/components/IdentityBadge'; -import { useGetTournamentCompetitor } from '~/services/tournamentCompetitors'; - -import styles from './PairableCompetitorCard.module.scss'; - -type PairableCompetitorCardRef = ElementRef; -type PairableCompetitorCardProps = ComponentPropsWithoutRef & { - competitorId: TournamentCompetitorId; - rank: number | null; - className?: string; -}; - -export const PairableCompetitorCard = forwardRef(({ - className, - competitorId, - rank, - ...props -}, ref) => { - const { data: competitor, loading } = useGetTournamentCompetitor({ id: competitorId }); - return ( - - -
- {rank === null ? '-' : rank + 1} -
-
- ); -}); diff --git a/src/components/TournamentPairingsGrid/PairableCompetitorCard/index.ts b/src/components/TournamentPairingsGrid/PairableCompetitorCard/index.ts deleted file mode 100644 index b9d15cb0..00000000 --- a/src/components/TournamentPairingsGrid/PairableCompetitorCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PairableCompetitorCard } from './PairableCompetitorCard'; diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.module.scss b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.module.scss deleted file mode 100644 index 23aeb6f3..00000000 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.module.scss +++ /dev/null @@ -1,44 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/variants"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.PairingsGridRow { - --indicator-size: 1.5rem; - - display: grid; - grid-template-columns: 1fr var(--indicator-size) 1fr; - flex-grow: 0; - gap: 0.5rem; - - &_Droppable { - height: 4.5rem; - } - - &_Indicator { - grid-column: 2/3; - grid-row: 1/2; - place-self: center center; - - &[data-valid="true"] { - color: var(--text-color-success); - } - - &[data-valid="false"] { - color: var(--text-color-negative); - } - - div { - @include flex.centered; - - width: var(--indicator-size); - height: var(--indicator-size); - } - - svg { - width: var(--indicator-size); - } - } -} diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx deleted file mode 100644 index 59f375bd..00000000 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion'; -import { CircleCheck, CircleX } from 'lucide-react'; - -import { TournamentCompetitorRanked } from '~/api'; -import { Draggable } from '../Draggable'; -import { Droppable } from '../Droppable/Droppable'; -import { PairableCompetitorCard } from '../PairableCompetitorCard'; -import { DraftTournamentPairing } from '../TournamentPairingsGrid.types'; -import { checkDraftPairingIsValid } from './PairingsGridRow.utils'; - -import styles from './PairingsGridRow.module.scss'; - -const iconVariants = { - initial: { opacity: 0, scale: 0.5 }, - animate: { opacity: 1, scale: 1, transition: { duration: 0.1 } }, - exit: { opacity: 0, scale: 0.5, transition: { duration: 0.05 } }, -}; - -export interface PairingsGridRowProps { - index: number; - pairing?: Partial; - activeCompetitor?: TournamentCompetitorRanked | null; -} - -export const PairingsGridRow = ({ - index, - pairing, - activeCompetitor, -}: PairingsGridRowProps): JSX.Element => { - const isValid = pairing && pairing[0] && pairing[1] ? checkDraftPairingIsValid(pairing) : undefined; - return ( -
-
- - {isValid && ( - - - - )} - {isValid === false && ( - - - - )} - -
- {[0, 1].map((j) => { - const competitor = pairing && pairing[j]; - const slotId = `${index}_${j}`; - const oppositeCompetitor = j === 0 ? (pairing && pairing[1]) : (pairing && pairing[0]); - const invalid = !!(activeCompetitor && oppositeCompetitor) && activeCompetitor.opponentIds.includes(oppositeCompetitor.id); - const style = { - gridColumn: j === 0 ? '1 / 2' : '3 / 4', - gridRow: '1 / 2', - }; - return ( - - {competitor && ( - - - - )} - - ); - })} -
- ); -}; diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts deleted file mode 100644 index a13691d4..00000000 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DraftTournamentPairing } from '../TournamentPairingsGrid.types'; - -/** - * Checks if a DraftPairing is valid. - * - * @param pairing - DraftPairing to check - * @returns True if the resulting TournamentPairing would be valid, false if not - */ -export const checkDraftPairingIsValid = ( - pairing: Partial, -): boolean => { - if (!pairing || !pairing[0] || !pairing[1]) { - return false; - } - if (pairing[0].id === pairing[1].id) { - return false; - } - if (pairing[0].opponentIds.includes(pairing[1].id)) { - return false; - } - if (pairing[1].opponentIds.includes(pairing[0].id)) { - return false; - } - return true; -}; diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/index.ts b/src/components/TournamentPairingsGrid/PairingsGridRow/index.ts deleted file mode 100644 index 392bd647..00000000 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { PairingsGridRowProps } from './PairingsGridRow'; -export { PairingsGridRow } from './PairingsGridRow'; diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.module.scss b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.module.scss deleted file mode 100644 index 71460e80..00000000 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.module.scss +++ /dev/null @@ -1,55 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/borders"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.PairingStep { - @include flex.column; - @include borders.normal; - @include shadows.surface; - @include corners.normal; - @include flex.stretchy; - - padding: var(--container-padding-y) var(--container-padding-x); - background: var(--card-bg); - - &_PairingMethodSection { - @include flex.row; - } -} - -.PairingsGrid { - display: grid; - grid-template-columns: 1fr 1.5rem 1fr 1fr; - gap: 1rem; -} - -.PairedSection { - @include flex.column($gap: 0.5rem); - - grid-column: 1 / 4; -} - -.UnpairedSection { - @include flex.column($gap: 0.5rem); - - grid-column: 4 / 5; -} - -.DragContent { - @include borders.normal; - @include shadows.surface; - @include corners.normal; - @include flex.stretchy; - - height: 5rem; - padding: calc(1rem - 1px); -} - -.UnpairedPool { - @include flex.column($gap: 0.5rem); - - flex: 1 auto; -} diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx deleted file mode 100644 index 6d8b840b..00000000 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useState, -} from 'react'; -import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, - rectIntersection, -} from '@dnd-kit/core'; -import { restrictToWindowEdges } from '@dnd-kit/modifiers'; -import isEqual from 'fast-deep-equal'; -import { AnimatePresence } from 'framer-motion'; - -import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; -import { Label } from '~/components/generic/Label'; -import { useTournament } from '~/components/TournamentProvider'; -import { Draggable } from './Draggable'; -import { Droppable } from './Droppable'; -import { PairableCompetitorCard } from './PairableCompetitorCard'; -import { PairingsGridRow } from './PairingsGridRow'; -import { DraftTournamentPairing, PairingsGridState } from './TournamentPairingsGrid.types'; -import { - buildGridState, - buildPairingResult, - convertPairingResultToCompetitorList, -} from './TournamentPairingsGrid.utils'; - -import styles from './TournamentPairingsGrid.module.scss'; - -const grabMotionDuration = 0.150; -const grabMotionInitial = { - scale: 1, - boxShadow: '0px 0px 0px rgba(0, 0, 0, 0)', -}; -const grabMotionAnimate = { - scale: 1.05, - boxShadow: '0px 8px 20px rgba(0, 0, 0, 0.2)', -}; -const grabAnimationProps = { - initial: grabMotionInitial, - animate: grabMotionAnimate, - exit: grabMotionInitial, - transition: { duration: grabMotionDuration }, -}; - -export interface TournamentPairingsGridProps { - defaultValue?: DraftTournamentPairing[]; - onChange: (value: DraftTournamentPairing[]) => void; -} - -export interface TournamentPairingsGridHandle { - reset: (pairings: DraftTournamentPairing[]) => void; - isDirty: boolean; -} - -export const TournamentPairingsGrid = forwardRef(({ - defaultValue, - onChange, -}: TournamentPairingsGridProps, ref): JSX.Element => { - const tournament = useTournament(); - const pairingIndexes = Array.from({ length: Math.ceil(tournament.maxCompetitors / 2) }, (_, i) => i); - - // Store competitors with their opponentIds so we can check pairing validity: - const competitors = useMemo(() => convertPairingResultToCompetitorList(defaultValue), [defaultValue]); - - // State: - const [activeCompetitorId, setActiveCompetitorId] = useState(null); - const [gridState, setGridState] = useState(null); - - // Set internal state from parent: - useEffect(() => { - if (defaultValue && !gridState) { - setGridState(buildGridState(defaultValue)); - } - }, [defaultValue, gridState]); - - const pairingResult = useMemo(() => buildPairingResult(competitors, gridState), [competitors, gridState]); - const isDirty = !isEqual(defaultValue, pairingResult); - - // Emit change to parent components: - useEffect(() => { - onChange(pairingResult); - }, [pairingResult, onChange]); - - // Allow parent to reset and track dirty state: - useImperativeHandle(ref, () => ({ - reset: (pairings: DraftTournamentPairing[]): void => setGridState(buildGridState(pairings)), - pairingResult, - isDirty, - })); - - useEffect(() => { - document.body.style.cursor = activeCompetitorId ? 'grabbing' : 'default'; - return () => { - document.body.style.cursor = 'default'; - }; - }, [activeCompetitorId]); - - const handleDragStart = ({ active }: DragStartEvent) => { - if (active) { - setActiveCompetitorId(active.id as TournamentCompetitorId); - } - }; - - const handleDragEnd = ({ active, over }: DragEndEvent) => { - if (!over || !gridState) { - return; - } - setActiveCompetitorId(null); - setGridState(Object.entries(gridState).map(([competitorId, slotId]) => { - - // If this ID is the active one, we're dragging it. Set it's slotID to 'over': - if (competitorId === active.id) { - return [competitorId, over.id]; - } - - // If this slot is the target, move its competitor to 'unpaired': - if (slotId === over.id) { - return [competitorId, 'unpaired']; - } - - // Otherwise do nothing: - return [competitorId, slotId]; - }).reduce((acc, [pairingCompetitorId, slotId]) => ({ - ...acc, - [pairingCompetitorId as TournamentCompetitorId]: slotId, - }), {})); - }; - - const unpairedCompetitors = competitors.filter((c) => gridState && gridState[c.id] === 'unpaired'); - const activeCompetitor = competitors.find((c) => c.id === activeCompetitorId); - const gridStatePivoted = Object.entries(gridState ?? {}).reduce((acc, [competitorId, slotId]) => ({ - ...acc, - [slotId]: competitors.find((c) => c.id === competitorId), - }), {} as Record); - - return ( - -
-
- - {pairingIndexes.map((i) => { - const pairing: DraftTournamentPairing = [ - gridStatePivoted[`${i}_0`] ?? null, - gridStatePivoted[`${i}_1`] ?? null, - ]; - return ( - - ); - })} -
-
- - - - {unpairedCompetitors.map((competitor) => ( - - - - ))} - - -
-
- - {activeCompetitor && ( - - - - - - )} - -
- ); -}); diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts deleted file mode 100644 index daf214a1..00000000 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; - -export type PairingsGridState = Record; - -export type DraftTournamentPairing = [TournamentCompetitorRanked | null, TournamentCompetitorRanked | null]; diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts deleted file mode 100644 index 4baa4169..00000000 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; -import { DraftTournamentPairing, PairingsGridState } from './TournamentPairingsGrid.types'; - -export const convertPairingResultToCompetitorList = (draftPairings?: DraftTournamentPairing[]): TournamentCompetitorRanked[] => { - if (!draftPairings) { - return []; - } - const competitors = new Set(); - draftPairings.forEach((pairing) => { - if (pairing[0]) { - competitors.add(pairing[0]); - } - if (pairing[1]) { - competitors.add(pairing[1]); - } - }); - return Array.from(competitors); -}; - -export const buildGridState = (draftPairings?: DraftTournamentPairing[]): Record => { - if (!draftPairings) { - return {}; - } - return draftPairings.reduce((acc, pairing, i) => { - if (pairing[0] && !pairing[1]) { - return { - ...acc, - [pairing[0].id]: 'unpaired', - }; - } - if (!pairing[0] && pairing[1]) { - return { - ...acc, - [pairing[1].id]: 'unpaired', - }; - } - if (pairing[0] && pairing[1]) { - return { - ...acc, - [pairing[0].id]: `${i}_0`, - [pairing[1].id]: `${i}_1`, - }; - } - return acc; - }, {} as Record); -}; - -export const buildPairingResult = (competitors: TournamentCompetitorRanked[], state: PairingsGridState | null): DraftTournamentPairing[] => { - if (!competitors?.length || !state || !Object.keys(state).length) { - return []; - } - const statefulCompetitors = competitors.map((competitor) => ({ - competitor, - slotId: state[competitor.id], - })); - - const pairings: DraftTournamentPairing[] = []; - - // Add all full pairings: - const pairedCompetitors = statefulCompetitors.filter(({ slotId }) => slotId !== 'unpaired'); - for (const { competitor, slotId } of pairedCompetitors) { - const [i,j] = slotId.split('_').map((i) => parseInt(i, 10)); - if (!pairings[i]) { - pairings[i] = [null, null] as unknown as DraftTournamentPairing; - } - pairings[i][j] = competitor; - } - - // Add all partial pairings: - const unpairedCompetitors = statefulCompetitors.filter(({ slotId }) => slotId === 'unpaired'); - for (const { competitor } of unpairedCompetitors) { - pairings.push([competitor, null]); - } - - return pairings; -}; diff --git a/src/components/TournamentPairingsGrid/index.ts b/src/components/TournamentPairingsGrid/index.ts deleted file mode 100644 index 0a628319..00000000 --- a/src/components/TournamentPairingsGrid/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - TournamentPairingsGrid, - type TournamentPairingsGridHandle, - type TournamentPairingsGridProps, -} from './TournamentPairingsGrid'; -export type { DraftTournamentPairing } from './TournamentPairingsGrid.types'; diff --git a/src/components/generic/Dialog/DialogDescription.module.scss b/src/components/generic/Dialog/DialogDescription.module.scss index e181637b..602b5303 100644 --- a/src/components/generic/Dialog/DialogDescription.module.scss +++ b/src/components/generic/Dialog/DialogDescription.module.scss @@ -11,4 +11,8 @@ @include flex.column($gap: 0.5rem); padding: 0 var(--modal-inner-gutter); + + strong { + font-weight: 500; + } } diff --git a/src/components/generic/FlagCircle/FlagCircle.module.scss b/src/components/generic/FlagCircle/FlagCircle.module.scss new file mode 100644 index 00000000..08715622 --- /dev/null +++ b/src/components/generic/FlagCircle/FlagCircle.module.scss @@ -0,0 +1,31 @@ +@use "/src/style/variables"; + +.FlagCircle { + flex-shrink: 0; + + aspect-ratio: 1; + width: 100%; + + border-radius: 100%; + + // A normal border pushes the background image 1px away from the edges giving the circle "flat sides" + box-shadow: inset var(--border-color-default) 0 0 0 1px; +} + +.FlagCircleCustom { + background-color: transparent; + background-repeat: no-repeat; + background-size: cover; + + &[data-code="xx-lkt"] { + background-image: url("./customFlags/1x1/xx-lkt.svg"); + } + + &[data-code="xx-mrc"] { + background-color: black; + } + + &[data-code="xx-prt"] { + background-image: url("./customFlags/1x1/xx-prt.svg"); + } +} diff --git a/src/components/generic/FlagCircle/FlagCircle.scss b/src/components/generic/FlagCircle/FlagCircle.scss deleted file mode 100644 index d4dd77a2..00000000 --- a/src/components/generic/FlagCircle/FlagCircle.scss +++ /dev/null @@ -1,17 +0,0 @@ -@use "/src/style/variables"; - -.FlagCircle { - flex-shrink: 0; - - aspect-ratio: 1; - width: 100%; - - border-radius: 100%; - - // A normal border pushes the background image 1px away from the edges giving the circle "flat sides" - box-shadow: inset var(--border-color-default) 0 0 0 1px; -} - -.FlagCircle-merc { - background-color: yellow; -} diff --git a/src/components/generic/FlagCircle/FlagCircle.tsx b/src/components/generic/FlagCircle/FlagCircle.tsx index 45022d3c..bed5aef5 100644 --- a/src/components/generic/FlagCircle/FlagCircle.tsx +++ b/src/components/generic/FlagCircle/FlagCircle.tsx @@ -1,7 +1,13 @@ import clsx from 'clsx'; import '/node_modules/flag-icons/css/flag-icons.min.css'; -import './FlagCircle.scss'; +import styles from './FlagCircle.module.scss'; + +const customCodes = [ + 'xx-lkt', + 'xx-mrc', + 'xx-prt', +]; export interface FlagCircleProps { className?: string; @@ -12,16 +18,17 @@ export const FlagCircle = ({ className, code, }: FlagCircleProps): JSX.Element => { - if (code === 'merc') { + if (customCodes.includes(code)) { return (
); } return (
); }; diff --git a/src/components/generic/FlagCircle/customFlags/1x1/xx-lkt.svg b/src/components/generic/FlagCircle/customFlags/1x1/xx-lkt.svg new file mode 100644 index 00000000..d44c9823 --- /dev/null +++ b/src/components/generic/FlagCircle/customFlags/1x1/xx-lkt.svg @@ -0,0 +1,638 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/generic/FlagCircle/customFlags/1x1/xx-prt.svg b/src/components/generic/FlagCircle/customFlags/1x1/xx-prt.svg new file mode 100644 index 00000000..200f0b45 --- /dev/null +++ b/src/components/generic/FlagCircle/customFlags/1x1/xx-prt.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/generic/FlagCircle/customFlags/4x3/xx-lkt.svg b/src/components/generic/FlagCircle/customFlags/4x3/xx-lkt.svg new file mode 100644 index 00000000..810d4301 --- /dev/null +++ b/src/components/generic/FlagCircle/customFlags/4x3/xx-lkt.svg @@ -0,0 +1,640 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/generic/FlagCircle/customFlags/4x3/xx-prt.svg b/src/components/generic/FlagCircle/customFlags/4x3/xx-prt.svg new file mode 100644 index 00000000..d4b76814 --- /dev/null +++ b/src/components/generic/FlagCircle/customFlags/4x3/xx-prt.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/generic/InfoPopover/InfoPopover.module.scss b/src/components/generic/InfoPopover/InfoPopover.module.scss index f6102bf9..e8e43406 100644 --- a/src/components/generic/InfoPopover/InfoPopover.module.scss +++ b/src/components/generic/InfoPopover/InfoPopover.module.scss @@ -29,6 +29,7 @@ will-change: transform, opacity; + max-width: 20rem; padding: 0.625rem 0.75rem 0.5rem; color: var(--primary-default-text); diff --git a/src/components/generic/InfoPopover/InfoPopover.tsx b/src/components/generic/InfoPopover/InfoPopover.tsx index 23f77941..011b8bc3 100644 --- a/src/components/generic/InfoPopover/InfoPopover.tsx +++ b/src/components/generic/InfoPopover/InfoPopover.tsx @@ -10,6 +10,7 @@ export interface InfoPopoverProps { className?: string; content: string; disableAutoHide?: boolean; + orientation?: 'vertical' | 'horizontal'; } export const InfoPopover = ({ @@ -18,6 +19,7 @@ export const InfoPopover = ({ className, content, disableAutoHide = false, + orientation = 'vertical', }: InfoPopoverProps): JSX.Element => { const [open, setOpen] = useState(false); const handleOpenChange = (open: boolean) => { @@ -34,7 +36,7 @@ export const InfoPopover = ({ {children} - + {content} diff --git a/src/components/generic/Pulsar/Pulsar.module.scss b/src/components/generic/Pulsar/Pulsar.module.scss new file mode 100644 index 00000000..3d254ece --- /dev/null +++ b/src/components/generic/Pulsar/Pulsar.module.scss @@ -0,0 +1,81 @@ +@use "/src/style/variables"; + +.Pulsar { + pointer-events: auto; + + position: relative; + transform: scale(0); + + overflow: visible; + + border-radius: 50%; + + transition: transform 300ms ease; + + &::before { + content: ""; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + width: 40px; + height: 40px; + } + + &[data-visible="true"] { + transform: scale(1); + } + + &[data-visible="false"] { + transform: scale(0); + } + + &[data-color="red"] { + background-color: var(--bg-red); + } + + &[data-color="blue"] { + background-color: var(--bg-blue); + } + + &[data-color="green"] { + background-color: var(--bg-green); + } + + &[data-color="yellow"] { + background-color: var(--bg-yellow); + } +} + +.Pulse { + pointer-events: none; + content: ""; + + position: absolute; + z-index: -1; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + opacity: 0.5; + background-color: inherit; + border-radius: 50%; + + animation: pulse 1.5s ease-out infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.5; + } + + 100% { + transform: scale(3); /* roughly 0.5rem outward */ + opacity: 0; + } +} diff --git a/src/components/generic/Pulsar/Pulsar.tsx b/src/components/generic/Pulsar/Pulsar.tsx new file mode 100644 index 00000000..a25fcedb --- /dev/null +++ b/src/components/generic/Pulsar/Pulsar.tsx @@ -0,0 +1,38 @@ +import { forwardRef, MouseEvent } from 'react'; + +import styles from './Pulsar.module.scss'; + +interface PulsarProps { + size?: number; + color?: 'red' | 'yellow' | 'green' | 'blue'; + visible?: boolean; + pulse?: boolean; + onClick?: (e: MouseEvent) => void; +} + +export const Pulsar = forwardRef(({ + size = 8, + color = 'blue', + visible = true, + pulse = true, + onClick, +}, ref) => ( +
{ + // e.preventDefault(); + onClick(e); + } : undefined} + style={{ + width: `${size}px`, + height: `${size}px`, + }} + > + {pulse && } +
+)); + +Pulsar.displayName = 'Pulsar'; diff --git a/src/components/generic/Pulsar/index.ts b/src/components/generic/Pulsar/index.ts new file mode 100644 index 00000000..68aca94d --- /dev/null +++ b/src/components/generic/Pulsar/index.ts @@ -0,0 +1,3 @@ +export { + Pulsar, +} from './Pulsar'; diff --git a/src/components/generic/SortableGrid/SortableGrid.module.scss b/src/components/generic/SortableGrid/SortableGrid.module.scss new file mode 100644 index 00000000..ec5aac28 --- /dev/null +++ b/src/components/generic/SortableGrid/SortableGrid.module.scss @@ -0,0 +1,5 @@ +.Grid { + user-select: none; + display: grid; + gap: 0.5rem; +} diff --git a/src/components/generic/SortableGrid/SortableGrid.tsx b/src/components/generic/SortableGrid/SortableGrid.tsx new file mode 100644 index 00000000..4f08a8d7 --- /dev/null +++ b/src/components/generic/SortableGrid/SortableGrid.tsx @@ -0,0 +1,139 @@ +import { + CSSProperties, + ReactNode, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { + closestCenter, + defaultDropAnimationSideEffects, + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + MouseSensor, + TouchSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arraySwap, + rectSwappingStrategy, + SortableContext, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import clsx from 'clsx'; + +import { SortableItem } from './components/SortableItem'; + +import styles from './SortableGrid.module.scss'; + +export interface SortableGridProps { + className?: string; + itemClassName?: string; + columns?: number; + items: UniqueIdentifier[]; + onChange: (items: UniqueIdentifier[]) => void; + renderItem: (id: UniqueIdentifier, state: { + activeId: UniqueIdentifier | null; + isActive: boolean; + isOverlay: boolean; + }) => ReactNode; +} + +export const SortableGrid = ({ + className, + itemClassName, + columns = 1, + items, + onChange, + renderItem, +}: SortableGridProps): JSX.Element => { + const [activeId, setActiveId] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor, { + // Disable smooth scrolling in Cypress automated tests + scrollBehavior: 'Cypress' in window ? 'auto' : undefined, + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const getIndex = (id: UniqueIdentifier): number => items.indexOf(id); + const activeIndex = activeId != null ? getIndex(activeId) : -1; + + const handleDragStart = ({ active }: DragStartEvent): void => { + if (!active) { + return; + } + setActiveId(active.id); + }; + const handleDragEnd = ({ over }: DragEndEvent): void => { + setActiveId(null); + if (over) { + const overIndex = getIndex(over.id); + if (activeIndex !== overIndex) { + const updated = arraySwap(items, activeIndex, overIndex); + onChange(updated); + } + } + }; + const handleDragCancel = (): void => { + setActiveId(null); + }; + + const style: CSSProperties = { + gridTemplateColumns: Array.from({ length: columns }).map((_) => '1fr').join(' '), + }; + + return ( + + +
+ {items.map((id) => ( + + {renderItem(id, { + activeId, + isActive: activeId === id, + isOverlay: false, + })} + + ))} +
+
+ {createPortal( + + {activeId != null ? ( + + {renderItem(activeId, { + activeId, + isActive: true, + isOverlay: true, + })} + + ) : null} + , + document.body, + )} +
+ ); +}; diff --git a/src/components/generic/SortableGrid/components/SortableItem.module.scss b/src/components/generic/SortableGrid/components/SortableItem.module.scss new file mode 100644 index 00000000..bd218ebc --- /dev/null +++ b/src/components/generic/SortableGrid/components/SortableItem.module.scss @@ -0,0 +1,80 @@ +@use "/src/style/shadows"; +@use "/src/style/variants"; +@use "/src/style/variables"; + +$box-shadow-down: 0 1px 2px 0 rgb(0 0 0 / 5%); +$box-shadow-lifted: + 0 4px 8px calc(1px / var(--scale-x, 1)) rgb(0 0 0 / 12%), + 0 12px 24px calc(2px / var(--scale-x, 1)) rgb(0 0 0 / 16%); + +.SortableItem { + &_Wrapper { + touch-action: manipulation; + + transform-origin: 0 0; + transform: + translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) + scaleX(var(--scale-x, 1)) scaleY(var(--scale-y, 1)); + + display: flex; + + box-sizing: border-box; + + &[data-overlay="true"] { + --scale: 1.05; + + z-index: 999; + } + } + + &_Content { + @include variants.card; + + touch-action: manipulation; + cursor: grab; + + position: relative; + transform-origin: 50% 50%; + transform: scale(1); + + display: flex; + flex-grow: 1; + align-items: center; + + box-sizing: border-box; + + background-color: var(--card-bg); + outline: none; + box-shadow: $box-shadow-down; + + transition: box-shadow 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22); + + -webkit-tap-highlight-color: transparent; + + &[data-ghost="true"] { + z-index: 0; + opacity: 0.5; + } + + &[data-overlay="true"] { + cursor: inherit; + transform: scale(1.05); + + // opacity: 1; + box-shadow: $box-shadow-lifted; + animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22); + } + } +} + +@keyframes pop { + 0% { + transform: scale(1); + box-shadow: $box-shadow-down; + } + + 100% { + transform: scale(1.05); + box-shadow: $box-shadow-lifted; + } +} diff --git a/src/components/generic/SortableGrid/components/SortableItem.tsx b/src/components/generic/SortableGrid/components/SortableItem.tsx new file mode 100644 index 00000000..9af78655 --- /dev/null +++ b/src/components/generic/SortableGrid/components/SortableItem.tsx @@ -0,0 +1,78 @@ +import { + forwardRef, + memo, + ReactNode, + useEffect, +} from 'react'; +import { UniqueIdentifier } from '@dnd-kit/core'; +import { arraySwap, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import clsx from 'clsx'; + +import styles from './SortableItem.module.scss'; + +interface SortableItemProps { + className?: string; + id: UniqueIdentifier; + children: ReactNode; + overlay?: boolean; +} + +export const SortableItem = memo(forwardRef(({ + className, + id, + children, + overlay = false, + ...props +}, ref) => { + const { + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ + id, + getNewIndex: ({ + id, + items, + activeIndex, + overIndex, + }) => arraySwap(items, activeIndex, overIndex).indexOf(id), + }); + + useEffect(() => { + if (!overlay) { + return; + } + document.body.style.cursor = 'grabbing'; + return () => { + document.body.style.cursor = ''; + }; + }, [overlay]); + + return ( +
+
+ {children} +
+
+ ); +})); diff --git a/src/components/generic/SortableGrid/components/index.ts b/src/components/generic/SortableGrid/components/index.ts new file mode 100644 index 00000000..15c074c2 --- /dev/null +++ b/src/components/generic/SortableGrid/components/index.ts @@ -0,0 +1,3 @@ +export { + SortableItem, +} from './SortableItem'; diff --git a/src/components/generic/SortableGrid/index.ts b/src/components/generic/SortableGrid/index.ts new file mode 100644 index 00000000..07a6f4e1 --- /dev/null +++ b/src/components/generic/SortableGrid/index.ts @@ -0,0 +1,3 @@ +export { + SortableGrid, +} from './SortableGrid'; diff --git a/src/components/generic/Table/Table.tsx b/src/components/generic/Table/Table.tsx index 26ad5e1f..d4c1960e 100644 --- a/src/components/generic/Table/Table.tsx +++ b/src/components/generic/Table/Table.tsx @@ -24,7 +24,7 @@ export const Table = ({
{rows.map((r, i) => ( - + ))}
diff --git a/src/components/generic/Table/Table.types.ts b/src/components/generic/Table/Table.types.ts index 14a72a80..617c5423 100644 --- a/src/components/generic/Table/Table.types.ts +++ b/src/components/generic/Table/Table.types.ts @@ -7,7 +7,7 @@ export type ColumnDef = { className?: string; key: string; label?: string; - renderCell?: (row: T) => ReactNode; + renderCell?: (row: T, index: number) => ReactNode; renderHeader?: () => ReactNode; width?: number; }; diff --git a/src/components/generic/Table/TableCell.tsx b/src/components/generic/Table/TableCell.tsx index d7c34854..43d65098 100644 --- a/src/components/generic/Table/TableCell.tsx +++ b/src/components/generic/Table/TableCell.tsx @@ -8,11 +8,13 @@ import styles from './Table.module.scss'; export interface TableCellProps { column: ColumnDef; row?: T; + index: number; } export const TableCell = ({ column, row, + index, }: TableCellProps): JSX.Element => { const className = clsx(styles.Table_Cell, column.className); const renderInner = (): ReactElement | null => { @@ -25,7 +27,7 @@ export const TableCell = ({ } if (row) { if (column.renderCell) { - const el = column.renderCell(row); + const el = column.renderCell(row, index); return isValidElement(el) ? el : {el}; } return {`${row?.[column.key]}`}; diff --git a/src/components/generic/Table/TableRow.tsx b/src/components/generic/Table/TableRow.tsx index 363e0fac..93f29c62 100644 --- a/src/components/generic/Table/TableRow.tsx +++ b/src/components/generic/Table/TableRow.tsx @@ -13,19 +13,21 @@ export interface TableRowProps { className?: string; columns: ColumnDef[]; row?: Row; + index?: number; } export const TableRow = ({ className, columns, row, + index = -1, }: TableRowProps): JSX.Element => { const gridTemplateColumns = columns.map((c) => c.width ? `${c.width}px` : '1fr').join(' '); if (!row) { return (
{columns.map((c) => ( - + ))}
); @@ -34,7 +36,7 @@ export const TableRow = ({ return (
{columns.map((c) => ( - + ))}
); diff --git a/src/components/generic/Warning/Warning.module.scss b/src/components/generic/Warning/Warning.module.scss new file mode 100644 index 00000000..744e70c1 --- /dev/null +++ b/src/components/generic/Warning/Warning.module.scss @@ -0,0 +1,46 @@ +@use "/src/style/flex"; +@use "/src/style/corners"; +@use "/src/style/borders"; +@use "/src/style/text"; +@use "/src/style/variables"; + +.Warning { + @include text.ui; + @include corners.normal; + @include borders.warning; + + display: grid; + grid-template-areas: + "icon header" + ". body"; + grid-template-columns: 1rem 1fr; + grid-template-rows: auto auto; + row-gap: 0.25rem; + column-gap: 0.5rem; + + padding: 1rem; + + color: var(--text-color-warning); + + background-color: var(--card-bg-warning); + + &_Icon { + grid-area: icon; + width: 1rem; + height: 1rem; + } + + &_Header { + grid-area: header; + } + + &_Body { + @include flex.column($gap: 0.5rem); + + grid-area: body; + + p { + color: inherit; + } + } +} diff --git a/src/components/generic/Warning/Warning.tsx b/src/components/generic/Warning/Warning.tsx new file mode 100644 index 00000000..cabb48ef --- /dev/null +++ b/src/components/generic/Warning/Warning.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react'; +import clsx from 'clsx'; +import { TriangleAlert } from 'lucide-react'; + +import styles from './Warning.module.scss'; + +export interface WarningProps { + className?: string; + children: ReactNode; +} + +export const Warning = ({ + className, + children, +}: WarningProps): JSX.Element => ( +
+ +

+ Warning +

+
+ {children} +
+
+); diff --git a/src/components/generic/Warning/index.ts b/src/components/generic/Warning/index.ts new file mode 100644 index 00000000..96b9f246 --- /dev/null +++ b/src/components/generic/Warning/index.ts @@ -0,0 +1,2 @@ +export type { WarningProps } from './Warning'; +export { Warning } from './Warning'; diff --git a/src/modals.ts b/src/modals.ts index 866d0d8e..accbb66d 100644 --- a/src/modals.ts +++ b/src/modals.ts @@ -1,5 +1,6 @@ import { useStore } from '@tanstack/react-store'; import { Store } from '@tanstack/store'; +import { v4 } from 'uuid'; interface Modal { id: string; @@ -24,7 +25,8 @@ export const closeModal = (id: string): void => { openModals.setState((state) => state.filter((modal) => modal.id !== id)); }; -export const useModal = (id: string) => { +export const useModal = (key?: string) => { + const id = key ?? v4(); const modal: Modal | undefined = useStore(openModals, (state) => state.find((openModal) => openModal.id === id)); return { id, diff --git a/src/pages/MatchResultDetailPage/components/MatchResultDetails.module.scss b/src/pages/MatchResultDetailPage/components/MatchResultDetails.module.scss deleted file mode 100644 index 9ab3d391..00000000 --- a/src/pages/MatchResultDetailPage/components/MatchResultDetails.module.scss +++ /dev/null @@ -1,65 +0,0 @@ -@use "/src/style/text"; -@use "/src/style/flex"; - -.Root { - @include text.ui; - - gap: 1rem; - - &[data-orientation="horizontal"] { - display: grid; - grid-template-areas: "meta meta" "set-up outcome"; - grid-template-columns: 1fr 1fr; - grid-template-rows: auto auto; - - .MetaSection { - @include flex.row; - } - } - - &[data-orientation="vertical"] { - @include flex.column; - - column-gap: 1rem; - } -} - -.MetaSection { - grid-area: meta; -} - -.SetUpSection { - display: grid; - grid-area: set-up; - grid-template-columns: auto 1fr; - row-gap: 0.25rem; - column-gap: 1rem; - - h3 { - grid-column: 1/3; - } -} - -.OutcomeSection { - display: grid; - grid-area: outcome; - grid-template-columns: auto 1fr; - row-gap: 0.25rem; - column-gap: 1rem; - - h3 { - grid-column: 1/3; - } -} - -.DetailLabel { - @include text.ui($muted: true); - - display: inline-block; -} - -.DetailValue { - @include text.ui; - - display: inline-block; -} diff --git a/src/pages/MatchResultDetailPage/components/MatchResultDetails.tsx b/src/pages/MatchResultDetailPage/components/MatchResultDetails.tsx deleted file mode 100644 index 19521b3c..00000000 --- a/src/pages/MatchResultDetailPage/components/MatchResultDetails.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import clsx from 'clsx'; - -import { fowV4BattlePlanOptions } from '~/api'; -import { useMatchResult } from '~/components/MatchResultProvider'; -import { useElementSize } from '~/hooks/useElementSize'; -import { formatOutcome } from '~/pages/MatchResultDetailPage/components/MatchResultDetails.utils'; - -import styles from './MatchResultDetails.module.scss'; - -export interface MatchResultDetailsProps { - className?: string; -} - -export const MatchResultDetails = ({ - className, -}: MatchResultDetailsProps): JSX.Element => { - const matchResult = useMatchResult(); - const [ref, width] = useElementSize(); - const orientation = Math.ceil(width) < 640 ? 'vertical' : 'horizontal'; // 2 x 320 + 1rem - 2x border - - const playerNames: [string, string] = [ - matchResult.player0User?.givenName || matchResult.player0User?.username || matchResult.player0Placeholder || 'Unknown Player', - matchResult.player1User?.givenName || matchResult.player1User?.username || matchResult.player1Placeholder || 'Unknown Player', - ]; - - return ( -
-
-

Meta

-
- Ruleset: - {matchResult.gameSystemId} (More Info) -
-
- Points: - {matchResult.gameSystemConfig.points} -
-
- Era: - {matchResult.gameSystemConfig.eraId} -
-
-
-

Game Set-Up

- {`${playerNames[0]}\u{2019}s Battle Plan:`} - {fowV4BattlePlanOptions.find((option) => option.value === matchResult.details.player0BattlePlan)?.label} - {`${playerNames[1]}\u{2019}s Battle Plan:`} - {fowV4BattlePlanOptions.find((option) => option.value === matchResult.details.player1BattlePlan)?.label} - Mission: - {matchResult.details.missionName} - Attacker: - {playerNames[matchResult.details.attacker]} - First Turn: - {playerNames[matchResult.details.firstTurn]} -
-
-

Outcome

- Turns Played: - {matchResult.details.turnsPlayed} - {`${playerNames[0]}\u{2019}s Units Lost:`} - {matchResult.details.player0UnitsLost} - {`${playerNames[1]}\u{2019}s Units Lost:`} - {matchResult.details.player1UnitsLost} - Outcome: - {formatOutcome(matchResult.details, playerNames)} -
-
- ); -}; diff --git a/src/pages/MatchResultDetailPage/components/MatchResultDetails.utils.ts b/src/pages/MatchResultDetailPage/components/MatchResultDetails.utils.ts deleted file mode 100644 index 5b0a2f5c..00000000 --- a/src/pages/MatchResultDetailPage/components/MatchResultDetails.utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { MatchResult } from '~/api'; - -export const formatOutcome = (details: MatchResult['details'], playerNames: [string, string]): string => { - if (details.winner !== -1 && details.outcomeType === 'attack_repelled') { - return `${playerNames[details.winner]} repelled the attack`; - } - if (details.winner !== -1 && details.outcomeType === 'objective_taken') { - return `${playerNames[details.winner]} took the objective`; - } - if (details.winner !== -1 && details.outcomeType === 'force_broken') { - if (details.winner === 0) { - return `${playerNames[0]} broke ${playerNames[1]}\u{2019}s formation(s)`; - } - if (details.winner === 1) { - return `${playerNames[1]} broke ${playerNames[0]}\u{2019}s formation(s)`; - } - } - return 'Draw / Time Out'; -}; diff --git a/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.hooks.ts b/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.hooks.ts deleted file mode 100644 index 1e756d24..00000000 --- a/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.hooks.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RefObject, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; - -export const useWizardSteps = (refs: RefObject<{ validate: () => void }>[]) => { - const navigate = useNavigate(); - const { pathname } = useLocation(); - const [step, setStep] = useState(0); - - const handleCancel = (): void => { - if (window.history.length > 1) { - navigate(-1); - } else { - navigate(`${pathname.split('/').slice(0, -1).join('/')}`); - } - }; - - const handleBack = (): void => { - if (step > 0) { - setStep(step - 1); - } else { - handleCancel(); - } - }; - - const handleProceed = (): void => { - refs[step].current?.validate(); - }; - - return { - step, - cancel: handleCancel, - back: handleBack, - advance: () => setStep(step + 1), - validatedAdvance: handleProceed, - }; -}; diff --git a/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.tsx b/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.tsx deleted file mode 100644 index e7419887..00000000 --- a/src/pages/TournamentAdvanceRoundPage/TournamentAdvanceRoundPage.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useRef } from 'react'; -import { - generatePath, - useNavigate, - useParams, -} from 'react-router-dom'; - -import { TournamentId, UnassignedTournamentPairing } from '~/api'; -import { Button } from '~/components/generic/Button'; -import { PageWrapper } from '~/components/PageWrapper'; -import { toast } from '~/components/ToastProvider'; -import { TournamentCompetitorsProvider } from '~/components/TournamentCompetitorsProvider'; -import { TournamentProvider } from '~/components/TournamentProvider'; -import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; -import { useGetTournament, useOpenTournamentRound } from '~/services/tournaments'; -import { PATHS } from '~/settings'; -import { PairingsStep } from './components/PairingsStep'; -import { PairingsStepHandle } from './components/PairingsStep/PairingsStep'; -import { RosterStep } from './components/RosterStep'; -import { RosterStepHandle } from './components/RosterStep/RosterStep'; -import { useWizardSteps } from './TournamentAdvanceRoundPage.hooks'; - -export const TournamentAdvanceRoundPage = (): JSX.Element => { - const params = useParams(); - const navigate = useNavigate(); - const tournamentId = params.id! as TournamentId; // Must exist or else how did we get to this route? - const { data: tournament } = useGetTournament({ id: tournamentId }); - const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ tournamentId }); - const { mutation: openTournamentRound } = useOpenTournamentRound({ - onSuccess: (): void => { - toast.success(`Round ${nextRound + 1} pairings created!`); - navigate(`${generatePath(PATHS.tournamentDetails, { id: tournamentId })}?tab=activeRound`); - }, - }); - - // Wizard - const rosterStepRef = useRef(null); - const pairingsStepRef = useRef(null); - const { - step, - cancel, - back, - advance, - validatedAdvance, - } = useWizardSteps([ - rosterStepRef, - pairingsStepRef, - ]); - - const onConfirmPairings = async (unassignedPairings: UnassignedTournamentPairing[]): Promise => { - await openTournamentRound({ - id: tournamentId, - unassignedPairings, - }); - }; - - if (!tournament || !tournamentCompetitors) { - return
Loading...
; - } - - const nextRound = (tournament.lastRound ?? -1) + 1; - - return ( - - - {(step > 0) && ( - - )} - - - } - > - - - {step === 0 && ( - - )} - {step === 1 && ( - - )} - - - - ); -}; diff --git a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss b/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss deleted file mode 100644 index a4173e73..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss +++ /dev/null @@ -1,12 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/borders"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.ConfirmPairingsDialog { - &_Content { - @include flex.column; - } -} diff --git a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx b/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx deleted file mode 100644 index e2b93281..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { UnassignedTournamentPairing } from '~/api'; -import { ConfirmationDialog } from '~/components/ConfirmationDialog'; -import { TournamentPairingRow } from '~/components/TournamentPairingRow'; -import { DraftTournamentPairing } from '~/components/TournamentPairingsGrid'; -import { convertDraftPairingsToUnassignedPairings } from '../PairingsStep/PairingsStep.utils'; - -import styles from './ConfirmPairingsDialog.module.scss'; - -export const confirmPairingsDialogId = 'confirm-pairings'; - -export interface ConfirmPairingsDialogProps { - nextRound: number; - manualPairings?: DraftTournamentPairing[]; - onConfirm: (pairings: UnassignedTournamentPairing[]) => void; -} - -export const ConfirmPairingsDialog = ({ - nextRound, - manualPairings = [], - onConfirm, -}: ConfirmPairingsDialogProps): JSX.Element => { - const unassignedPairings = convertDraftPairingsToUnassignedPairings(manualPairings); - - const handleConfirm = () => { - if (!unassignedPairings.length) { - throw new Error('cannot confirm non-existent pairings!'); - } - onConfirm(unassignedPairings); - }; - - return ( - -
-

The following pairings will be created:

- {unassignedPairings.map((pairing, i) => ( - - ))} -
-
- ); -}; diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.module.scss b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.module.scss deleted file mode 100644 index 2256ae52..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.module.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/borders"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.PairingStep { - @include flex.column; - @include borders.normal; - @include shadows.surface; - @include corners.normal; - @include flex.stretchy; - - padding: var(--container-padding-y) var(--container-padding-x); - background: var(--card-bg); - - &_PairingMethodSection { - @include flex.row; - } - - &_ConfirmationContent { - @include flex.column; - } - - &_ConfirmationSection { - @include flex.column($gap: 0.5rem); - } -} diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx deleted file mode 100644 index 3e02f25b..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useRef, - useState, -} from 'react'; - -import { - TournamentPairingMethod, - tournamentPairingMethodOptions, - UnassignedTournamentPairing, -} from '~/api'; -import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; -import { Button } from '~/components/generic/Button'; -import { InputSelect } from '~/components/generic/InputSelect'; -import { Label } from '~/components/generic/Label'; -import { Separator } from '~/components/generic/Separator'; -import { - DraftTournamentPairing, - TournamentPairingsGrid, - TournamentPairingsGridHandle, -} from '~/components/TournamentPairingsGrid'; -import { useTournament } from '~/components/TournamentProvider'; -import { useGetDraftTournamentPairings } from '~/services/tournamentPairings'; -import { ConfirmPairingsDialog, confirmPairingsDialogId } from '../ConfirmPairingsDialog'; - -import styles from './PairingsStep.module.scss'; - -const changePairingMethodConfirmDialogId = 'confirm-change-pairing-method'; -const resetPairingsConfirmDialogId = 'confirm-reset-pairings'; - -export interface PairingsStepProps { - nextRound: number; - onConfirm: (pairings: UnassignedTournamentPairing[]) => void; -} - -export interface PairingsStepHandle { - validate: () => void; -} - -export const PairingsStep = forwardRef(({ - nextRound, - onConfirm, -}: PairingsStepProps, ref): JSX.Element => { - const tournament = useTournament(); - - // Pairing state - const isFirstRound = nextRound === 0; - const defaultPairingMethod = isFirstRound ? 'random' : tournament.pairingMethod; - const [pairingMethod, setPairingMethod] = useState(defaultPairingMethod); - const { data: draftPairingResults } = useGetDraftTournamentPairings({ - tournamentId: tournament._id, - round: nextRound, - method: pairingMethod, - }); - const [manualPairings, setManualPairings] = useState(); - useEffect(() => { - if (draftPairingResults) { - setManualPairings(draftPairingResults); - } - }, [draftPairingResults]); - - const pairingsGridRef = useRef(null); - const isDirty = pairingsGridRef.current?.isDirty ?? false; - - const { open: openChangePairingMethodConfirmDialog } = useConfirmationDialog(changePairingMethodConfirmDialogId); - const { open: openResetPairingsConfirmDialog } = useConfirmationDialog(resetPairingsConfirmDialogId); - const { open: openConfirmPairingsDialog } = useConfirmationDialog(confirmPairingsDialogId); - - const handleChangePairingMethod = (value: TournamentPairingMethod): void => { - if (isDirty) { - openChangePairingMethodConfirmDialog({ - onConfirm: () => setPairingMethod(value), - }); - } else { - setPairingMethod(value); - } - }; - - const handleReset = (): void => { - if (draftPairingResults) { - if (isDirty) { - openResetPairingsConfirmDialog({ - onConfirm: () => pairingsGridRef.current?.reset(draftPairingResults), - }); - } else { - setManualPairings(draftPairingResults); - } - } - }; - - useImperativeHandle(ref, () => ({ - validate: openConfirmPairingsDialog, - })); - - return ( -
-
- - - -
- - - - - -
- ); -}); diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.utils.ts b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.utils.ts deleted file mode 100644 index e9b957ac..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { UnassignedTournamentPairing } from '~/api'; -import { DraftTournamentPairing } from '~/components/TournamentPairingsGrid'; - -export const convertDraftPairingsToUnassignedPairings = ( - draftTournamentPairings: DraftTournamentPairing[], -): UnassignedTournamentPairing[] => draftTournamentPairings.filter(([a, b]) => a || b).map(([a, b]) => { - const playedTables = Array.from( - new Set([ - ...(a?.playedTables ?? []), - ...(b?.playedTables ?? []), - ]), - ); - if (a && !b) { - return { - tournamentCompetitor0Id: a.id, - tournamentCompetitor1Id: null, - playedTables, - }; - } - if (!a && b) { - return { - tournamentCompetitor0Id: b.id, - tournamentCompetitor1Id: null, - playedTables, - }; - } - // We've filtered out pairings with no competitors, and handled the one-sided ones above: - return { - tournamentCompetitor0Id: a!.id, - tournamentCompetitor1Id: b!.id, - playedTables, - }; -}); diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/index.ts b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/index.ts deleted file mode 100644 index 350d9f76..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PairingsStep } from './PairingsStep'; diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.module.scss b/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.module.scss deleted file mode 100644 index 68fb5952..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.module.scss +++ /dev/null @@ -1,33 +0,0 @@ -@use "/src/style/flex"; -@use "/src/style/variables"; -@use "/src/style/variants"; -@use "/src/style/borders"; -@use "/src/style/corners"; -@use "/src/style/shadows"; -@use "/src/style/text"; - -.RosterStep { - @include flex.column; - @include borders.normal; - @include shadows.surface; - @include corners.normal; - - padding: var(--container-padding-y) var(--container-padding-x); - background: var(--card-bg); - - &_Header { - @include flex.row; - } - - &_Actions { - @include flex.row; - - margin-left: auto; - } - - &_CompetitorContent { - @include flex.column; - - padding: 1rem calc(1rem - var(--border-width)); - } -} diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.tsx b/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.tsx deleted file mode 100644 index 54a46249..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - forwardRef, - ReactNode, - useImperativeHandle, - useMemo, -} from 'react'; -import { Plus } from 'lucide-react'; - -import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; -import { Button } from '~/components/generic/Button'; -import { Separator } from '~/components/generic/Separator'; -import { TournamentCompetitorCreateDialog, useTournamentCompetitorCreateDialog } from '~/components/TournamentCompetitorCreateDialog'; -import { useTournamentCompetitors } from '~/components/TournamentCompetitorsProvider'; -import { useTournament } from '~/components/TournamentProvider'; -import { TournamentRoster } from '~/components/TournamentRoster'; -import { getWarnings, sortCompetitorsByActive } from './RosterStep.utils'; - -import styles from './RosterStep.module.scss'; - -const tournamentRosterConfirmDialogId = 'tournament-roster-confirm'; - -export interface RosterStepProps { - nextRound: number; - onConfirm: () => void; -} - -export interface RosterStepHandle { - validate: () => void; -} - -export const RosterStep = forwardRef(({ - onConfirm, - nextRound, -}: RosterStepProps, ref) => { - const tournament = useTournament(); - const tournamentCompetitors = useTournamentCompetitors(); - const { open: openTournamentCompetitorCreateDialog } = useTournamentCompetitorCreateDialog(); - const { - open: openTournamentRosterConfirmDialog, - close: closeTournamentRosterConfirmDialog, - } = useConfirmationDialog(tournamentRosterConfirmDialogId); - - const sortedCompetitors = sortCompetitorsByActive(tournamentCompetitors); - - useImperativeHandle(ref, () => ({ - validate: () => { - if (!sortedCompetitors.active.length) { - // TODO: Use a toast instead and return - throw new Error('No competitors'); - } - if (sortedCompetitors.active.length > tournament.maxCompetitors) { - // TODO: Use a toast instead and return - throw new Error('Too many competitors!'); - } - for (const competitor of sortedCompetitors.active) { - const activePlayers = competitor.players.filter(({ active }) => active); - if (activePlayers.length > tournament.competitorSize) { - // TODO: Use a toast instead and return - throw new Error('One or more competitors have too many players!'); - } - if (activePlayers.length < tournament.competitorSize) { - // TODO: Use a toast instead and return - throw new Error('One or more competitors have too few players!'); - } - } - if (warnings.length > 0) { - openTournamentRosterConfirmDialog(); - } else { - onConfirm(); - } - }, - })); - - const handleConfirm = () => { - closeTournamentRosterConfirmDialog(); - onConfirm(); - }; - - const warnings: ReactNode[] = useMemo(() => getWarnings(tournament, tournamentCompetitors), [ - tournament, - tournamentCompetitors, - ]); - - return ( - <> -
-
-

- {`Adjust ${tournament.useTeams ? 'Teams' : 'Players'}`} -

-
- -
-
- - -
- - - - ); -}); diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx b/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx deleted file mode 100644 index 651dfeb0..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { ReactNode } from 'react'; - -import { Tournament, TournamentCompetitor } from '~/api'; -import { IdentityBadge } from '~/components/IdentityBadge'; - -export const sortCompetitorsByActive = (tournamentCompetitors: TournamentCompetitor[]) => ( - tournamentCompetitors.reduce( - (acc, c) => { - const key = c.active ? 'active' : 'inactive'; - acc[key].push(c); - return acc; - }, - { active: [] as TournamentCompetitor[], inactive: [] as TournamentCompetitor[] }, - ) -); - -export const getWarnings = (tournament: Tournament, tournamentCompetitors: TournamentCompetitor[]): ReactNode[] => { - const round = (tournament.currentRound ?? 0) + 1; - const sortedCompetitors = sortCompetitorsByActive(tournamentCompetitors); - const inactiveCompetitorCount = sortedCompetitors.inactive.length ?? 0; - const warnings: ReactNode[] = []; - if (sortedCompetitors.inactive.length > 0) { - warnings.push( - <> -

- {`The following ${tournament.useTeams ? ('team' + (inactiveCompetitorCount > 1 ? 's are' : ' is')) : 'player(s)'} not listed as checked in and will not be included in the pairing process for round ${round}.`} -

- {sortedCompetitors?.inactive.map((tournamentCompetitor) => ( - - ))} - , - ); - } - if (sortedCompetitors.active.length % 2) { - warnings.push( -

- {`There is an odd number of competitors, so one competitor will remain unpaired. As tournament organizer, you will need to submit match results for the ${tournament.useTeams ? 'team' : 'player'} with a bye, with the desired outcome.`} -

, - ); - } - return warnings; -}; diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/index.ts b/src/pages/TournamentAdvanceRoundPage/components/RosterStep/index.ts deleted file mode 100644 index 2b0d1f8f..00000000 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RosterStep } from './RosterStep'; diff --git a/src/pages/TournamentAdvanceRoundPage/index.ts b/src/pages/TournamentAdvanceRoundPage/index.ts deleted file mode 100644 index 56bd669b..00000000 --- a/src/pages/TournamentAdvanceRoundPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TournamentAdvanceRoundPage } from './TournamentAdvanceRoundPage'; diff --git a/src/pages/TournamentDetailPage/TournamentDetailPage.tsx b/src/pages/TournamentDetailPage/TournamentDetailPage.tsx index d9c26eb2..59e95de4 100644 --- a/src/pages/TournamentDetailPage/TournamentDetailPage.tsx +++ b/src/pages/TournamentDetailPage/TournamentDetailPage.tsx @@ -16,6 +16,7 @@ import { } from '~/components/generic/Tabs'; import { NotFoundView } from '~/components/NotFoundView'; import { PageWrapper } from '~/components/PageWrapper'; +import { TournamentActionsProvider } from '~/components/TournamentActionsProvider'; import { TournamentCompetitorsProvider } from '~/components/TournamentCompetitorsProvider'; import { TournamentContextMenu } from '~/components/TournamentContextMenu'; import { TournamentProvider } from '~/components/TournamentProvider'; @@ -105,40 +106,47 @@ export const TournamentDetailPage = (): JSX.Element => { return ( - - } bannerBackgroundUrl={tournament.bannerUrl}> -
- {showInfoSidebar && ( -
- -
- )} - -
- {tabs.length > 1 && ( - - )} - -
- - - - - - - - - - - - - - - -
-
-
-
+ + + } + bannerBackgroundUrl={tournament.bannerUrl} + title={tournament.title} + hideTitle + > +
+ {showInfoSidebar && ( +
+ +
+ )} + +
+ {tabs.length > 1 && ( + + )} + +
+ + + + + + + + + + + + + + + +
+
+
+
+
); }; diff --git a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx index e3e91291..a9d2df5b 100644 --- a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx @@ -2,8 +2,10 @@ import { ReactElement, useState } from 'react'; import clsx from 'clsx'; import { Zap } from 'lucide-react'; +import { Button } from '~/components/generic/Button'; import { InputSelect } from '~/components/generic/InputSelect'; import { Table } from '~/components/generic/Table'; +import { useTournamentActions } from '~/components/TournamentActionsProvider/TournamentActionsProvider.hooks'; import { useTournament } from '~/components/TournamentProvider'; import { useGetTournamentPairings } from '~/services/tournamentPairings'; import { TournamentDetailCard } from '../TournamentDetailCard'; @@ -19,33 +21,28 @@ export interface TournamentPairingsCardProps { export const TournamentPairingsCard = ({ className, }: TournamentPairingsCardProps): JSX.Element => { - const { _id: tournamentId, currentRound, lastRound } = useTournament(); - const [round, setRound] = useState(currentRound ?? 0); + const { _id: tournamentId, lastRound, roundCount } = useTournament(); + const actions = useTournamentActions(); + + const roundIndexes = lastRound !== undefined ? Array.from({ + length: Math.min(lastRound + 2, roundCount), + }, (_, i) => i) : [0]; + const [round, setRound] = useState(roundIndexes.length - 1); + const { data: tournamentPairings, loading } = useGetTournamentPairings({ tournamentId, round, }); + const columns = getTournamentPairingTableConfig(); const rows = (tournamentPairings || []); const showEmptyState = !loading && !rows.length; const showLoadingState = loading; - const getRoundIndexes = (): number[] => { - if (currentRound === undefined && lastRound === undefined) { - return []; - } - if (currentRound === undefined && lastRound !== undefined) { - return Array.from({ length: lastRound + 1 }, (_, i) => i); - } - if (currentRound !== undefined) { - return Array.from({ length: currentRound + 1 }, (_, i) => i); - } - return []; - }; - const roundOptions = getRoundIndexes().map((round) => ({ - label: `Round ${round + 1}`, - value: round, + const roundOptions = roundIndexes.map((i) => ({ + label: `Round ${i + 1}`, + value: i, })); const getPrimaryButtons = (): ReactElement[] | undefined => [ @@ -53,27 +50,35 @@ export const TournamentPairingsCard = ({ options={roundOptions} value={round} onChange={(selected) => setRound(selected as number)} - disabled={showLoadingState || showEmptyState} + disabled={showLoadingState || roundIndexes.length < 2} />, ]; return ( - - {showLoadingState ? ( -
- Loading... -
- ) : ( - showEmptyState ? ( - } /> + <> + + {showLoadingState ? ( +
+ Loading... +
) : ( - - ) - )} - + showEmptyState ? ( + }> + {actions?.configureRound && ( + + )} + + ) : ( +
+ ) + )} + + ); }; diff --git a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx index b9893753..6189d93b 100644 --- a/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentRosterCard/TournamentRosterCard.tsx @@ -11,6 +11,7 @@ import { import { useAuth } from '~/components/AuthProvider'; import { Button } from '~/components/generic/Button'; import { toast } from '~/components/ToastProvider'; +import { TournamentCompetitorCreateDialog, useTournamentCompetitorCreateDialog } from '~/components/TournamentCompetitorCreateDialog'; import { useTournamentCompetitors } from '~/components/TournamentCompetitorsProvider'; import { TournamentCreateTeamDialog } from '~/components/TournamentCreateTeamDialog'; import { useTournamentCreateTeamDialog } from '~/components/TournamentCreateTeamDialog/TournamentCreateTeamDialog.hooks'; @@ -42,6 +43,7 @@ export const TournamentRosterCard = ({ const { mutation: publishTournament } = usePublishTournament({ successMessage: `${tournament.title} is now live!`, }); + const { open: openTournamentCompetitorCreateDialog } = useTournamentCompetitorCreateDialog(); const showLoadingState = loading; const showEmptyState = !loading && !tournamentCompetitors?.length; @@ -74,8 +76,10 @@ export const TournamentRosterCard = ({ const getPrimaryButtons = (): ReactElement[] | undefined => { const isPlayer = user && tournament.playerUserIds.includes(user._id); + const isOrganizer = user && tournament.organizerUserIds.includes(user._id); const hasMaxTeams = (competitors || []).length >= tournament.maxCompetitors; - if (user && !isPlayer && !hasMaxTeams) { + + if (tournament.status === 'published' && user && !isPlayer && !hasMaxTeams) { if (tournament.useTeams) { return [ , ]; } + if (tournament.status === 'active' && tournament.currentRound === undefined && isOrganizer) { + return [ + , + ]; + } }; const emptyStateProps = tournament.status === 'draft' && isOrganizer ? { @@ -124,6 +135,7 @@ export const TournamentRosterCard = ({ ) )} + ); }; diff --git a/src/pages/TournamentDetailPage/index.ts b/src/pages/TournamentDetailPage/index.ts index e69de29b..5e9436f8 100644 --- a/src/pages/TournamentDetailPage/index.ts +++ b/src/pages/TournamentDetailPage/index.ts @@ -0,0 +1,3 @@ +export { + TournamentDetailPage, +} from './TournamentDetailPage'; diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss b/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss new file mode 100644 index 00000000..6f8ab9a2 --- /dev/null +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss @@ -0,0 +1,78 @@ +@use "/src/style/flex"; +@use "/src/style/variables"; +@use "/src/style/borders"; +@use "/src/style/corners"; +@use "/src/style/shadows"; +@use "/src/style/text"; +@use "/src/style/variants"; + +.TournamentPairingsPage { + @include flex.column; + @include variants.card; + + &_Header { + @include flex.row; + + padding: var(--container-padding-y) var(--container-padding-x) 0; + } + + &_TableRow { + padding: 0 var(--container-padding-x); + } + + &_Form { + display: grid; + grid-template-areas: + ". tableHeader competitorsHeader" + "alerts tableInputs competitorsGrid"; + grid-template-columns: auto 6rem 1fr; + grid-template-rows: auto auto; + gap: 1rem; + + padding: 0 var(--container-padding-x) var(--container-padding-y); + + &_Alerts { + display: grid; + grid-area: alerts; + gap: 0.5rem; + place-items: center; + justify-content: center; + + width: 1.5rem; + } + + &_TableHeader { + grid-area: tableHeader; + } + + &_CompetitorsHeader { + grid-area: competitorsHeader; + } + + &_CompetitorsGrid { + grid-area: competitorsGrid; + } + + &_CompetitorCard { + @include flex.row; + + padding: 0.5rem; + } + + &_TableInputs { + display: grid; + grid-area: tableInputs; + gap: 0.5rem; + } + + &_TableInput { + display: flex; + align-items: center; + justify-content: center; + + button { + width: 100%; + } + } + } +} diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.schema.ts b/src/pages/TournamentPairingsPage/TournamentPairingsPage.schema.ts new file mode 100644 index 00000000..a4f9e738 --- /dev/null +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { TournamentCompetitorId } from '~/api'; + +const tournamentCompetitorIdSchema = z.union([ + z.string().transform((val) => val as TournamentCompetitorId), + z.null(), +]); + +export const draftTournamentPairingSchema = z.object({ + table: z.union([z.number(), z.null()]), + tournamentCompetitor0Id: tournamentCompetitorIdSchema, + tournamentCompetitor1Id: tournamentCompetitorIdSchema, +}); + +export type TournamentPairingFormItem = z.infer; + +export const schema = z.object({ + pairings: z.array(draftTournamentPairingSchema), +}); + +export type FormData = z.infer; + +export const sanitize = ( + pairings: unknown[] = [], +): TournamentPairingFormItem[] => pairings.map((p) => draftTournamentPairingSchema.parse(p)); diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx b/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx new file mode 100644 index 00000000..c0769e50 --- /dev/null +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx @@ -0,0 +1,269 @@ +import { + MouseEvent, + useCallback, + useEffect, + useState, +} from 'react'; +import { + useFieldArray, + useForm, + useWatch, +} from 'react-hook-form'; +import { + generatePath, + useNavigate, + useParams, +} from 'react-router-dom'; +import { UniqueIdentifier } from '@dnd-kit/core'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import { + DraftTournamentPairing, + TournamentId, + TournamentPairingMethod, + tournamentPairingMethodOptions, +} from '~/api'; +import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; +import { Button } from '~/components/generic/Button'; +import { InfoPopover } from '~/components/generic/InfoPopover'; +import { InputSelect } from '~/components/generic/InputSelect'; +import { Label } from '~/components/generic/Label'; +import { Pulsar } from '~/components/generic/Pulsar'; +import { Separator } from '~/components/generic/Separator'; +import { SortableGrid } from '~/components/generic/SortableGrid'; +import { PageWrapper } from '~/components/PageWrapper'; +import { toast } from '~/components/ToastProvider'; +import { TournamentCompetitorsProvider } from '~/components/TournamentCompetitorsProvider'; +import { TournamentProvider } from '~/components/TournamentProvider'; +import { ConfirmPairingsDialog } from '~/pages/TournamentPairingsPage/components/ConfirmPairingsDialog'; +import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; +import { useCreateTournamentPairings, useGetDraftTournamentPairings } from '~/services/tournamentPairings'; +import { useGetTournament } from '~/services/tournaments'; +import { PATHS } from '~/settings'; +import { + FormData, + sanitize, + schema, +} from './TournamentPairingsPage.schema'; +import { + flattenPairings, + getPairingsStatuses, + renderCompetitorCard, + updatePairings, +} from './TournamentPairingsPage.utils'; + +import styles from './TournamentPairingsPage.module.scss'; + +const WIDTH = 800; + +export const TournamentPairingsPage = (): JSX.Element => { + const params = useParams(); + const navigate = useNavigate(); + + const tournamentId = params.id! as TournamentId; // Must exist or else how did we get to this route? + const { data: tournament } = useGetTournament({ id: tournamentId }); + const lastRound = tournament?.lastRound ?? -1; + + const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ + tournamentId, + includeRankings: lastRound, + }); + const isFirstRound = (tournament?.lastRound ?? -1) < 0; + const defaultPairingMethod = isFirstRound ? 'random' : (tournament?.pairingMethod ?? 'adjacent'); + const [pairingMethod, setPairingMethod] = useState(defaultPairingMethod); + + const round = lastRound + 1; + const { data: generatedPairings } = useGetDraftTournamentPairings(tournament ? { + tournamentId, + round, + method: pairingMethod, + } : 'skip'); + + const { mutation: createTournamentPairings } = useCreateTournamentPairings({ + onSuccess: (): void => { + toast.success(`Round ${round + 1} pairings created!`); + navigate(`${generatePath(PATHS.tournamentDetails, { id: tournamentId })}?tab=pairings`); + }, + }); + + const { + id: confirmChangePairingMethodDialogId, + open: openConfirmChangePairingMethodDialog, + } = useConfirmationDialog(); + const { + id: confirmResetPairingsDialogId, + open: openConfirmResetPairingsDialog, + } = useConfirmationDialog(); + const { + id: confirmPairingsDialogId, + open: openConfirmPairingsDialog, + } = useConfirmationDialog(); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + pairings: sanitize(generatedPairings), + }, + mode: 'onSubmit', + }); + const reset = useCallback((pairings: unknown[]) => form.reset({ + pairings: sanitize(pairings), + }), [form]); + useEffect(() => { + if (tournament && generatedPairings) { + reset(generatedPairings); + } + }, [tournament, generatedPairings, reset]); + const { fields } = useFieldArray({ + control: form.control, + name: 'pairings', + }); + const pairings = useWatch({ + control: form.control, + name: 'pairings', + }); + + if (!tournament || !tournamentCompetitors) { + return
Loading...
; + } + + const tableCount = Math.ceil(tournament.maxCompetitors / 2); + const tableOptions = [ + ...Array.from({ length: tableCount }).map((_, i) => ({ + label: String(i + 1), + value: i, + })), + { label: 'Auto', value: -1 }, + ]; + + const handleChange = (items: UniqueIdentifier[]): void => { + updatePairings(items, form.reset); + }; + + const handleChangePairingMethod = (value: TournamentPairingMethod): void => { + if (form.formState.isDirty) { + openConfirmChangePairingMethodDialog({ + onConfirm: () => setPairingMethod(value), + }); + } else { + setPairingMethod(value); + } + }; + + const handleReset = (): void => { + if (generatedPairings) { + if (form.formState.isDirty) { + openConfirmResetPairingsDialog({ + onConfirm: () => reset(generatedPairings), + }); + } else { + reset(generatedPairings); + } + } + }; + + const handleCancel = (_e: MouseEvent): void => { + // TODO: If dirty, open confirmation dialog + navigate(-1); + }; + + const handleProceed = (_e: MouseEvent): void => { + openConfirmPairingsDialog(); + }; + + const handleConfirm = async (pairings: DraftTournamentPairing[]): Promise => { + await createTournamentPairings({ tournamentId, round, pairings }); + }; + + const pairingStatuses = getPairingsStatuses(tournamentCompetitors, pairings); + + return ( + + + + + } + > + + +
+
+ + + +
+ +
+
+ {fields.map((field, i) => { + const { status, message } = pairingStatuses[i]; + const statusColors: Record = { + 'error': 'red', + 'warning': 'yellow', + 'ok': 'green', + }; + return ( + + + + ); + })} +
+ +
+ {fields.map((field, i) => ( +
+ form.setValue(`pairings.${i}.table`, value as number, { shouldDirty: true })} + /> +
+ ))} +
+ + renderCompetitorCard(id, state, tournamentCompetitors)} + /> +
+
+ + + +
+
+
+ ); +}; diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx b/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx new file mode 100644 index 00000000..f3cf4905 --- /dev/null +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx @@ -0,0 +1,175 @@ +import { ReactElement } from 'react'; +import { UseFormReset } from 'react-hook-form'; +import { UniqueIdentifier } from '@dnd-kit/core'; + +import { TournamentCompetitor, TournamentCompetitorId } from '~/api'; +import { Pulsar } from '~/components/generic/Pulsar'; +import { IdentityBadge } from '~/components/IdentityBadge'; +import { getTournamentCompetitorDisplayName } from '~/utils/common/getTournamentCompetitorDisplayName'; +import { FormData, TournamentPairingFormItem } from './TournamentPairingsPage.schema'; + +import styles from './TournamentPairingsPage.module.scss'; + +export const updatePairings = (items: UniqueIdentifier[], reset: UseFormReset): void => reset(({ pairings }) => ({ + pairings: pairings.map((p, i) => { + const tournamentCompetitor0Id = items[i * 2] === 'bye' ? null : items[i * 2] as TournamentCompetitorId; + const tournamentCompetitor1Id = items[i * 2 + 1] === 'bye' ? null : items[i * 2 + 1] as TournamentCompetitorId; + return { + ...p, + tournamentCompetitor0Id, + tournamentCompetitor1Id, + }; + }), +}), { + keepDefaultValues: true, +}); + +/** + * + * @param pairings + * @returns + */ +export const flattenPairings = ( + pairings: TournamentPairingFormItem[] = [], +): UniqueIdentifier[] => { + const result: UniqueIdentifier[] = []; + for (const pairing of pairings) { + result.push(pairing.tournamentCompetitor0Id ?? 'bye'); + result.push(pairing.tournamentCompetitor1Id ?? 'bye'); + } + return result; +}; + +export type PairingStatus = { + status: 'error' | 'warning' | 'ok'; + message: string; +}; + +/** + * + * @param rankedCompetitors + * @param pairings + * @returns + */ +export const getPairingsStatuses = ( + rankedCompetitors: TournamentCompetitor[], + pairings: TournamentPairingFormItem[], +): PairingStatus[] => { + const tableCount: Record = {}; + + // First pass: Count table numbers: + for (const pairing of pairings) { + // Count up table assignments which are not null or "auto" (-1): + if (pairing.table !== null && pairing.table !== -1) { + tableCount[pairing.table] = (tableCount[pairing.table] || 0) + 1; + } + } + + // Second pass: Mark duplicates: + const hasDuplicateTableAssignments = pairings.map((pairing) => ( + pairing.table !== null && tableCount[pairing.table] > 1 + )); + + return pairings.map((pairing, i) => { + const competitorA = rankedCompetitors.find((c) => c._id === pairing.tournamentCompetitor0Id); + const competitorB = rankedCompetitors.find((c) => c._id === pairing.tournamentCompetitor1Id); + + if (hasDuplicateTableAssignments[i]) { + return { + status: 'error', + message: 'This table is assigned more than once.', + }; + } + if (competitorA && competitorB) { + if ( + (competitorA?.opponentIds ?? []).includes(competitorB._id) || + (competitorB?.opponentIds ?? []).includes(competitorA._id) + ) { + return { + status: 'error', + message: 'These opponents have already played each other.', + }; + } + } + if (competitorA && !competitorB && (competitorA?.byeRounds ?? []).length) { + const displayName = getTournamentCompetitorDisplayName(competitorA); + return { + status: 'warning', + message: `${displayName} has already had a bye.`, + }; + } + if (!competitorA && competitorB && (competitorB?.byeRounds ?? []).length) { + const displayName = getTournamentCompetitorDisplayName(competitorB); + return { + status: 'warning', + message: `${displayName} has already had a bye.`, + }; + } + if (pairing.table !== null && pairing.table > -1) { + if (competitorA && (competitorA?.playedTables ?? []).includes(pairing.table)) { + const displayName = getTournamentCompetitorDisplayName(competitorA); + return { + status: 'warning', + message: `${displayName} has already played this table.`, + }; + } + if (competitorB && (competitorB?.playedTables ?? []).includes(pairing.table)) { + const displayName = getTournamentCompetitorDisplayName(competitorB); + return { + status: 'warning', + message: `${displayName} has already played this table.`, + }; + } + } + + return { + status: 'ok', + message: 'Pairing is valid.', + }; + }); +}; + +/** + * + * @param id + * @param state + * @returns + */ +export const renderCompetitorCard = ( + id: UniqueIdentifier, + state: { + activeId: UniqueIdentifier | null, + isActive: boolean, + isOverlay: boolean, + }, + tournamentCompetitors: TournamentCompetitor[], +): ReactElement => { + + if (!tournamentCompetitors) { + return ( +
+ +
+ ); + } + + const rankedCompetitor = tournamentCompetitors.find((c) => c._id === id); + + if (!rankedCompetitor) { + return ( +
+ +
+ ); + } + + const isValid = !!state.activeId && !(rankedCompetitor?.opponentIds ?? []).includes(state.activeId as TournamentCompetitorId); + + return ( +
+ +
{rankedCompetitor.rank !== undefined ? rankedCompetitor.rank + 1 : '-'}
+ +
+ ); +}; diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss new file mode 100644 index 00000000..d1eac8eb --- /dev/null +++ b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss @@ -0,0 +1,49 @@ +@use "/src/style/variables"; +@use "/src/style/flex"; +@use "/src/style/text"; + +.ConfirmPairingsDialog { + padding-bottom: var(--container-padding-y); + + &_Content { + margin: 0 var(--container-padding-x); + } + + &_Filters { + @include flex.row; + } + + &_EmptyState { + @include flex.stretchy; + @include flex.centered; + @include text.ui($muted: true); + + padding-bottom: 4rem; + } + + &_Row { + padding: 0 var(--container-padding-x); + } + + &_Table { + @include text.ui; + + font-size: 1.5rem; + font-weight: 300; + line-height: 1.75rem; + } + + &_Pairing { + padding: 0.5rem; + } + + &_MatchIndicatorIcon { + width: 1.25rem; + height: 1.25rem; + stroke: currentcolor; + } + + &_MatchIndicatorInner { + @include text.ui; + } +} diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx new file mode 100644 index 00000000..fdf0efb9 --- /dev/null +++ b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx @@ -0,0 +1,82 @@ +import { DraftTournamentPairing, TournamentCompetitor } from '~/api'; +import { ConfirmationDialog } from '~/components/ConfirmationDialog'; +import { ColumnDef, Table } from '~/components/generic/Table'; +import { Warning } from '~/components/generic/Warning'; +import { TournamentPairingRow } from '~/components/TournamentPairingRow'; +import { useTournament } from '~/components/TournamentProvider'; +import { TournamentPairingFormItem } from '../../TournamentPairingsPage.schema'; +import { assignTables } from './ConfirmPairingsDialog.utils'; + +import styles from './ConfirmPairingsDialog.module.scss'; + +export interface ConfirmPairingsDialogProps { + competitors: TournamentCompetitor[]; + id: string; + onConfirm: (assignedPairings: DraftTournamentPairing[]) => void; + pairings: TournamentPairingFormItem[]; +} + +export const ConfirmPairingsDialog = ({ + competitors, + id, + onConfirm, + pairings, +}: ConfirmPairingsDialogProps): JSX.Element => { + const { maxCompetitors } = useTournament(); + + const assignedPairings = assignTables(pairings.filter((pairing) => ( + pairing.tournamentCompetitor0Id || pairing.tournamentCompetitor1Id + )).map((pairing) => ({ + ...pairing, + playedTables: Array.from(new Set([ + ...competitors.find((c) => c._id === pairing.tournamentCompetitor0Id)?.playedTables ?? [], + ...competitors.find((c) => c._id === pairing.tournamentCompetitor1Id)?.playedTables ?? [], + ])), + })), Math.ceil(maxCompetitors / 2)); + + const handleConfirm = (): void => { + onConfirm(assignedPairings); + }; + + const columns: ColumnDef[] = [ + { + key: 'table', + label: 'Table', + width: 40, + align: 'center', + renderCell: (r) => ( +
+ {r.table === null ? '-' : r.table + 1} +
+ ), + }, + { + key: 'pairing', + label: 'Pairing', + align: 'center', + renderCell: (r) => ( + + ), + }, + ]; + + return ( + +

+ The following pairings will be created: +

+
+ + Once created, pairings cannot be edited. Please ensure all competitors are present and ready to play! + + + ); +}; diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.ts b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.ts new file mode 100644 index 00000000..e828f2a9 --- /dev/null +++ b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.ts @@ -0,0 +1,74 @@ +import { DraftTournamentPairing } from '~/api'; +import { TournamentPairingFormItem } from '../../TournamentPairingsPage.schema'; + +export const assignTables = ( + pairings: (TournamentPairingFormItem & { + playedTables: (number | null)[]; + })[], + tableCount: number, +): DraftTournamentPairing[] => { + + // Get tables already in use: + const usedTables = new Set(pairings.filter((p) => p.table !== null && p.table > -1).map((p) => p.table)); + + // Create pool of available tables: + const availableTables = Array.from({ length: tableCount }, (_, i) => i).filter((table) => !usedTables.has(table)); + + const assignedPairings = []; + + for (const { playedTables, ...pairing } of pairings) { + const ids = [ + pairing.tournamentCompetitor0Id, + pairing.tournamentCompetitor1Id, + ].filter((id) => id !== null); + + // If not a bye... + if (ids.length === 2) { + const fullPairing = { + ...pairing, + tournamentCompetitor0Id: ids[0], + tournamentCompetitor1Id: ids[1], + }; + + // ...and the table is assigned, don't do anything: + if (pairing.table !== -1) { + assignedPairings.push(fullPairing); + } else { + // Otherwise, find best available table (prefer unplayed tables): + const table = availableTables.find((table) => !playedTables.includes(table)) ?? availableTables[0] ?? null; + + // Remove assigned table from available pool: + const index = availableTables.indexOf(table); + if (index > -1) { + availableTables.splice(index, 1); + } + + assignedPairings.push({ + ...fullPairing, + table, + }); + } + + } + + // If it is a bye, force table to be null: + if (ids.length === 1) { + assignedPairings.push({ + table: null, + tournamentCompetitor0Id: ids[0], + tournamentCompetitor1Id: null, + }); + } + + // Ignore pairings which are empty. + } + return assignedPairings.sort((a, b) => { + if (a.table === null) { + return 1; + } + if (b.table === null) { + return -1; + } + return a.table - b.table; + }); +}; diff --git a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/index.ts b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts similarity index 66% rename from src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/index.ts rename to src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts index 9396ec78..bb257aca 100644 --- a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/index.ts +++ b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts @@ -1,4 +1,4 @@ export { ConfirmPairingsDialog, - confirmPairingsDialogId, + type ConfirmPairingsDialogProps, } from './ConfirmPairingsDialog'; diff --git a/src/pages/TournamentPairingsPage/index.ts b/src/pages/TournamentPairingsPage/index.ts new file mode 100644 index 00000000..1202d929 --- /dev/null +++ b/src/pages/TournamentPairingsPage/index.ts @@ -0,0 +1,3 @@ +export { + TournamentPairingsPage, +} from './TournamentPairingsPage'; diff --git a/src/routes.tsx b/src/routes.tsx index 8cf47220..6783efd6 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -25,11 +25,10 @@ import { SettingsPage, UserProfileForm, } from '~/pages/SettingsPage'; -import { TournamentAdvanceRoundPage } from '~/pages/TournamentAdvanceRoundPage'; import { TournamentCreatePage } from '~/pages/TournamentCreatePage'; -import { TournamentDetailPage } from '~/pages/TournamentDetailPage/TournamentDetailPage'; +import { TournamentDetailPage } from '~/pages/TournamentDetailPage'; import { TournamentEditPage } from '~/pages/TournamentEditPage/TournamentEditPage'; -// import { TournamentDetailPage } from '~/pages/TournamentDetailPage'; +import { TournamentPairingsPage } from '~/pages/TournamentPairingsPage'; import { TournamentsPage } from '~/pages/TournamentsPage'; import { PATHS } from '~/settings'; @@ -134,9 +133,9 @@ export const routes = [ element: , }, { - path: PATHS.tournamentAdvanceRound, + path: PATHS.tournamentPairings, visibility: [], - element: , + element: , }, { path: PATHS.tournamentEdit, diff --git a/src/services/tournamentPairings.ts b/src/services/tournamentPairings.ts index 554482a4..cc8115ef 100644 --- a/src/services/tournamentPairings.ts +++ b/src/services/tournamentPairings.ts @@ -1,5 +1,5 @@ import { api } from '~/api'; -import { createQueryHook } from '~/services/utils'; +import { createMutationHook, createQueryHook } from '~/services/utils'; // Basic Queries export const useGetTournamentPairing = createQueryHook(api.tournamentPairings.getTournamentPairing); @@ -8,3 +8,6 @@ export const useGetTournamentPairings = createQueryHook(api.tournamentPairings.g // Special Queries export const useGetActiveTournamentPairingsByUser = createQueryHook(api.tournamentPairings.getActiveTournamentPairingsByUser); export const useGetDraftTournamentPairings = createQueryHook(api.tournamentPairings.getDraftTournamentPairings); + +// Mutations +export const useCreateTournamentPairings = createMutationHook(api.tournamentPairings.createTournamentPairings); diff --git a/src/services/tournaments.ts b/src/services/tournaments.ts index e9d7d7b8..c6d34789 100644 --- a/src/services/tournaments.ts +++ b/src/services/tournaments.ts @@ -16,8 +16,8 @@ export const useUpdateTournament = createMutationHook(api.tournaments.updateTour export const useDeleteTournament = createMutationHook(api.tournaments.deleteTournament); // Special Mutations -export const useCloseTournamentRound = createMutationHook(api.tournaments.closeTournamentRound); export const useEndTournament = createMutationHook(api.tournaments.endTournament); -export const useOpenTournamentRound = createMutationHook(api.tournaments.openTournamentRound); +export const useEndTournamentRound = createMutationHook(api.tournaments.endTournamentRound); export const usePublishTournament = createMutationHook(api.tournaments.publishTournament); export const useStartTournament = createMutationHook(api.tournaments.startTournament); +export const useStartTournamentRound = createMutationHook(api.tournaments.startTournamentRound); diff --git a/src/settings.ts b/src/settings.ts index 002f6da5..f2dd5d39 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -16,9 +16,9 @@ export const PATHS = { dashboard: '/dashboard', matchResultDetails: '/match-results/:id', matchResults: '/match-results', - tournamentAdvanceRound: '/tournaments/:id/advance', tournamentCreate: '/tournaments/create', tournamentDetails: '/tournaments/:id', tournamentEdit: '/tournaments/:id/edit', + tournamentPairings: '/tournaments/:id/pairings', tournaments: '/tournaments', } as const; diff --git a/src/style/_variables.scss b/src/style/_variables.scss index 78805343..05666b2d 100644 --- a/src/style/_variables.scss +++ b/src/style/_variables.scss @@ -26,6 +26,12 @@ @use "@radix-ui/colors/tomato-dark.css"; @use "@radix-ui/colors/tomato.css"; +// Info +@use "@radix-ui/colors/blue-alpha.css"; +@use "@radix-ui/colors/blue-dark-alpha.css"; +@use "@radix-ui/colors/blue-dark.css"; +@use "@radix-ui/colors/blue.css"; + :root { --modal-inner-gutter: 1rem; --modal-outer-gutter: 0.5rem; @@ -66,6 +72,13 @@ --text-color-negative: var(--tomato-9); --text-color-success: var(--grass-9); --text-color-warning: var(--amber-11); + --text-color-blue: var(--blue-9); + + // Solid backgrounds + --bg-blue: var(--blue-9); + --bg-green: var(--grass-9); + --bg-red: var(--tomato-9); + --bg-yellow: var(--amber-9); // VARIANTS diff --git a/src/utils/common/getCountryName.ts b/src/utils/common/getCountryName.ts index e8c65463..7c8d67b9 100644 --- a/src/utils/common/getCountryName.ts +++ b/src/utils/common/getCountryName.ts @@ -1,13 +1,20 @@ import { country, subdivision } from 'iso-3166-2'; export const getCountryName = (code: string): string | undefined => { - if (code === 'merc') { + if (code === 'xx-lkt') { + return 'Landsknechte'; + } + if (code === 'xx-mrc') { return 'Mercenaries'; - } else { - if (code.includes('-')) { - return subdivision(code)?.name; - } else { - return country(code)?.name; - } } + if (code === 'xx-prt') { + return 'Pirates'; + } + if (code === 'un') { + return 'United Nations'; + } + if (code.includes('-')) { + return subdivision(code)?.name; + } + return country(code)?.name; }; diff --git a/src/utils/common/getCountryOptions.ts b/src/utils/common/getCountryOptions.ts index f3680e57..9bd9c0a8 100644 --- a/src/utils/common/getCountryOptions.ts +++ b/src/utils/common/getCountryOptions.ts @@ -39,7 +39,6 @@ export const getEtcCountryOptions = (): InputSelectOption[] => [ 'ie', 'is', 'it', - 'merc', 'nl', 'nz', 'pl', @@ -47,6 +46,12 @@ export const getEtcCountryOptions = (): InputSelectOption[] => [ 'ro', 'se', 'us', + + // Mercenary teams + 'xx-lkt', // Landsknecht + 'xx-mrc', // Mercenaries + 'xx-prt', // Pirates + 'un', // United Nations ].map( (code) => ({ label: getCountryName(code) || 'Unknown Country', value: code }), ).sort(