diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 35ab2830..6ec3b664 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -66,6 +66,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 +81,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 +115,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"; @@ -249,6 +252,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 +267,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 +301,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/aggregateFowV4TournamentData.ts b/convex/_model/fowV4/aggregateFowV4TournamentData.ts index b2e4c124..1af355e7 100644 --- a/convex/_model/fowV4/aggregateFowV4TournamentData.ts +++ b/convex/_model/fowV4/aggregateFowV4TournamentData.ts @@ -131,11 +131,11 @@ export const aggregateFowV4TournamentData = async ( } return { - players: flattenFowV4StatMap(playerStats).filter(({ gamesPlayed }) => gamesPlayed).map(({ id, stats }) => ({ + players: flattenFowV4StatMap(playerStats).map(({ id, stats }) => ({ id, stats, })), - competitors: flattenFowV4StatMap(competitorStats).filter(({ gamesPlayed }) => gamesPlayed).map(({ id, stats }) => ({ + competitors: flattenFowV4StatMap(competitorStats).map(({ id, stats }) => ({ id, stats, ...competitorMeta[id], 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/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..2cf22a39 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 && 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/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/index.ts b/convex/_model/tournaments/index.ts index 5493bd62..68172dbe 100644 --- a/convex/_model/tournaments/index.ts +++ b/convex/_model/tournaments/index.ts @@ -20,10 +20,6 @@ export { getTournamentShallow } from './_helpers/getTournamentShallow'; export { getTournamentUserIds } from './_helpers/getTournamentUserIds'; // Mutations -export { - closeTournamentRound, - closeTournamentRoundArgs, -} from './mutations/closeTournamentRound'; export { createTournament, createTournamentArgs, @@ -37,9 +33,9 @@ export { endTournamentArgs, } from './mutations/endTournament'; export { - openTournamentRound, - openTournamentRoundArgs, -} from './mutations/openTournamentRound'; + endTournamentRound, + endTournamentRoundArgs, +} from './mutations/endTournamentRound'; export { publishTournament, publishTournamentArgs, @@ -48,6 +44,10 @@ export { startTournament, startTournamentArgs, } from './mutations/startTournament'; +export { + startTournamentRound, + startTournamentRoundArgs, +} from './mutations/startTournamentRound'; export { updateTournament, updateTournamentArgs, 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..2643fa5d 100644 --- a/convex/_model/tournaments/queries/getTournamentOpenRound.ts +++ b/convex/_model/tournaments/queries/getTournamentOpenRound.ts @@ -46,8 +46,8 @@ export const getTournamentOpenRound = async ( return { round: tournament.currentRound, matchResultsProgress: { - submitted: relevantPairingIds.length * tournament.competitorSize, - required: relevantMatchResultIds.length, + required: relevantPairingIds.length * tournament.competitorSize, + submitted: relevantMatchResultIds.length, }, // TODO: Get timer }; diff --git a/convex/common/errors.ts b/convex/common/errors.ts index 90a52f21..ec67e853 100644 --- a/convex/common/errors.ts +++ b/convex/common/errors.ts @@ -13,11 +13,6 @@ export const errors = { CANNOT_REMOVE_COMPETITOR_FROM_ACTIVE_TOURNAMENT: 'Cannot add a competitor to 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.', @@ -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..f38f4fd8 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 diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts b/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts index 98aacbbf..29d02da2 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts +++ b/src/components/ConfirmationDialog/ConfirmationDialog.hooks.ts @@ -6,4 +6,4 @@ type ConfirmationDialogData = { 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..635231a6 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,6 +1,5 @@ import { ReactNode } from 'react'; import clsx from 'clsx'; -import { TriangleAlert } from 'lucide-react'; import { Button } from '~/components/generic/Button'; import { @@ -23,8 +22,8 @@ export interface ConfirmationDialogProps { intent?: ElementIntent; onConfirm?: () => void; title: string; - warnings?: ReactNode[]; disabled?: boolean; + disablePadding?: boolean; } export const ConfirmationDialog = ({ @@ -35,8 +34,8 @@ export const ConfirmationDialog = ({ intent = 'default', onConfirm, title, - warnings = [], disabled = false, + disablePadding = false, }: ConfirmationDialogProps): JSX.Element => { const { close, data } = useConfirmationDialog(id); const handleConfirm = (): void => { @@ -57,26 +56,9 @@ export const ConfirmationDialog = ({ {data?.description || description} )} - {warnings.length > 0 && ( -
- {warnings.map((warning, i) => ( -
- -

- Warning -

-
- {warning} -
-
- ))} -
- )} - {children && ( -
- {children} -
- )} +
+ {children} +
- + <> + + + + + ); }; diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss new file mode 100644 index 00000000..62aea60f --- /dev/null +++ b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.module.scss @@ -0,0 +1,5 @@ +@use "/src/style/flex"; + +.ConfirmConfigureRoundDialog { + @include flex.column; +} diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx new file mode 100644 index 00000000..546fe582 --- /dev/null +++ b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.tsx @@ -0,0 +1,95 @@ +import { + forwardRef, + ReactNode, + useImperativeHandle, + useMemo, +} from 'react'; +import { generatePath, useNavigate } from 'react-router-dom'; +import clsx from 'clsx'; + +import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; +import { Warning } from '~/components/generic/Warning'; +import { toast } from '~/components/ToastProvider'; +import { useTournament } from '~/components/TournamentProvider'; +import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; +import { PATHS } from '~/settings'; +import { getTournamentCompetitorDisplayName } from '~/utils/common/getTournamentCompetitorDisplayName'; +import { getWarnings, sortCompetitorsByActive } from './ConfirmConfigureRoundDialog.utils'; + +import styles from './ConfirmConfigureRoundDialog.module.scss'; + +export interface ConfirmConfigureRoundDialogProps { + className?: string; +} + +export interface ConfirmConfigureRoundDialogHandle { + open: () => void; +} + +export const ConfirmConfigureRoundDialog = forwardRef(({ + className, +}: ConfirmConfigureRoundDialogProps, ref): JSX.Element => { + const tournament = useTournament(); + const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ + tournamentId: tournament._id, + }); + const navigate = useNavigate(); + const { id, open } = useConfirmationDialog(); + const proceed = (): void => { + navigate(generatePath(PATHS.tournamentPairings, { id: tournament._id })); + }; + + const { active: activeCompetitors } = sortCompetitorsByActive(tournamentCompetitors ?? []); + const warnings: ReactNode[] = useMemo(() => getWarnings(tournament, tournamentCompetitors ?? []), [ + tournament, + tournamentCompetitors, + ]); + + useImperativeHandle(ref, () => ({ + open: () => { + if (activeCompetitors.length < 2) { + return toast.error('Cannot Configure Round', { + description: 'Please ensure at least 2 competitors are active.', + }); + } + if (activeCompetitors.length > tournament.maxCompetitors) { + return toast.error('Cannot Configure Round', { + description: `There are too many active competitors. Please disable ${activeCompetitors.length - tournament.maxCompetitors} to proceed.`, + }); + } + for (const competitor of activeCompetitors) { + const activePlayers = competitor.players.filter(({ active }) => active); + if (activePlayers.length > tournament.competitorSize) { + return toast.error('Cannot Configure Round', { + description: `${getTournamentCompetitorDisplayName(competitor)} has too many active players.`, + }); + } + if (activePlayers.length < tournament.competitorSize) { + return toast.error('Cannot Configure Round', { + description: `${getTournamentCompetitorDisplayName(competitor)} has too few active players.`, + }); + } + } + + if (warnings.length) { + open(); + } else { + proceed(); + } + }, + })); + return ( + + {warnings.map((warning, i) => ( + + {warning} + + ))} + + ); +}); diff --git a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx similarity index 66% rename from src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx rename to src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx index 651dfeb0..c7ee414b 100644 --- a/src/pages/TournamentAdvanceRoundPage/components/RosterStep/RosterStep.utils.tsx +++ b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/ConfirmConfigureRoundDialog.utils.tsx @@ -15,23 +15,22 @@ export const sortCompetitorsByActive = (tournamentCompetitors: TournamentCompeti ); 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 round = (tournament.lastRound ?? -1) + 2; + const { active, inactive } = sortCompetitorsByActive(tournamentCompetitors); const warnings: ReactNode[] = []; - if (sortedCompetitors.inactive.length > 0) { + if (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}.`} + {`The following ${tournament.useTeams ? ('team' + ((inactive.length ?? 0) > 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) => ( + {inactive.map((tournamentCompetitor) => ( ))} , ); } - if (sortedCompetitors.active.length % 2) { + if (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.`} diff --git a/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts new file mode 100644 index 00000000..61bf2d13 --- /dev/null +++ b/src/components/TournamentContextMenu/components/ConfirmConfigureRoundDialog/index.ts @@ -0,0 +1,5 @@ +export { + ConfirmConfigureRoundDialog, + type ConfirmConfigureRoundDialogHandle, + type ConfirmConfigureRoundDialogProps, +} from './ConfirmConfigureRoundDialog'; diff --git a/src/components/TournamentContextMenu/index.ts b/src/components/TournamentContextMenu/index.ts index d1c00880..1ce08722 100644 --- a/src/components/TournamentContextMenu/index.ts +++ b/src/components/TournamentContextMenu/index.ts @@ -1 +1,5 @@ +export { + ConfirmConfigureRoundDialog, + type ConfirmConfigureRoundDialogHandle, +} from './components/ConfirmConfigureRoundDialog'; export { TournamentContextMenu } from './TournamentContextMenu'; 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/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/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/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/components/TournamentPairingsCard/TournamentPairingsCard.tsx b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx index e3e91291..9808731f 100644 --- a/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx +++ b/src/pages/TournamentDetailPage/components/TournamentPairingsCard/TournamentPairingsCard.tsx @@ -1,9 +1,15 @@ -import { ReactElement, useState } from 'react'; +import { + ReactElement, + useRef, + 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 { ConfirmConfigureRoundDialog, ConfirmConfigureRoundDialogHandle } from '~/components/TournamentContextMenu'; import { useTournament } from '~/components/TournamentProvider'; import { useGetTournamentPairings } from '~/services/tournamentPairings'; import { TournamentDetailCard } from '../TournamentDetailCard'; @@ -19,33 +25,25 @@ 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 } = useTournament(); + + const roundIndexes = lastRound !== undefined ? Array.from({ length: lastRound + 2 }, (_, i) => i) : [0]; + const [round, setRound] = useState(roundIndexes.length - 1); const { data: tournamentPairings, loading } = useGetTournamentPairings({ tournamentId, round, }); + + const confirmConfigureRoundDialogRef = useRef(null); 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 +51,39 @@ export const TournamentPairingsCard = ({ options={roundOptions} value={round} onChange={(selected) => setRound(selected as number)} - disabled={showLoadingState || showEmptyState} + disabled={showLoadingState || roundIndexes.length < 2} />, ]; + const handleConfigure = (): void => { + confirmConfigureRoundDialogRef.current?.open(); + }; + return ( - - {showLoadingState ? ( -
- Loading... -
- ) : ( - showEmptyState ? ( - } /> + <> + + {showLoadingState ? ( +
+ Loading... +
) : ( - - ) - )} - + showEmptyState ? ( + }> + + + ) : ( +
+ ) + )} + + + + ); }; 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