From 5828853164fd875b021286b46cce9efb61b172a3 Mon Sep 17 00:00:00 2001 From: javaisbetterthanpython Date: Sun, 7 Jun 2026 00:00:09 -0400 Subject: [PATCH] Add in-game fixed pair management via PlayersModal. Pair link controls let players be linked during an active game; editPlayers persists fixedPairs and regenerates the round when pairs change. Co-authored-by: Cursor --- pages/rounds.tsx | 3 +- src/PlayerPairSelect.tsx | 53 +++++++++++++ src/PlayersModal.tsx | 158 +++++++++++++++++++++++++-------------- src/fixedPairs.ts | 46 ++++++++++++ src/useShuffler.tsx | 19 ++++- 5 files changed, 219 insertions(+), 60 deletions(-) create mode 100644 src/PlayerPairSelect.tsx diff --git a/pages/rounds.tsx b/pages/rounds.tsx index a39f7f9..8319f14 100644 --- a/pages/rounds.tsx +++ b/pages/rounds.tsx @@ -78,9 +78,10 @@ export default function Rounds() { setPlayersModal(false)} - onSubmit={async (newPlayers, regenerate) => { + onSubmit={async (newPlayers, fixedPairs, regenerate) => { await editPlayers(dispatch, state, worker, { newPlayers, + fixedPairs, regenerate, }); if (!regenerate && roundIndex) setRoundIndex((index) => index + 1); diff --git a/src/PlayerPairSelect.tsx b/src/PlayerPairSelect.tsx new file mode 100644 index 0000000..2c1b466 --- /dev/null +++ b/src/PlayerPairSelect.tsx @@ -0,0 +1,53 @@ +import { Select, SelectItem } from "@nextui-org/react"; +import { PlayerId } from "./matching/heuristics"; +import { getPartnerId } from "./fixedPairs"; + +type PlayerOption = { id: PlayerId; name: string }; + +export function PlayerPairSelect({ + playerId, + playerName, + players, + fixedPairs, + onPairChange, + disabled, +}: { + playerId: PlayerId; + playerName: string; + players: PlayerOption[]; + fixedPairs: [PlayerId, PlayerId][]; + onPairChange: (playerId: PlayerId, partnerId: PlayerId | null) => void; + disabled?: boolean; +}) { + const partnerId = getPartnerId(playerId, fixedPairs); + const partnerOptions = players.filter((p) => p.id !== playerId); + + return ( + + ); +} diff --git a/src/PlayersModal.tsx b/src/PlayersModal.tsx index 9ff4518..88c6ad2 100644 --- a/src/PlayersModal.tsx +++ b/src/PlayersModal.tsx @@ -10,10 +10,17 @@ import { } from "@nextui-org/react"; import { v4 as uuidv4 } from "uuid"; import { useEffect, useRef, useState } from "react"; -import { AddUser, Delete } from "react-iconly"; -import { Player } from "./matching/heuristics"; +import { AddUser, Delete, Link } from "react-iconly"; +import { Player, Team } from "./matching/heuristics"; import { useShufflerState } from "./useShuffler"; import clsx from "clsx"; +import { + getPartnerId, + pairsEqual, + sanitizeFixedPairs, + setPlayerPair, +} from "./fixedPairs"; +import { PlayerPairSelect } from "./PlayerPairSelect"; export function PlayersModal({ open, @@ -22,7 +29,11 @@ export function PlayersModal({ }: { open: boolean; onClose: () => void; - onSubmit: (newPlayers: Player[], regenerate: boolean) => void; + onSubmit: ( + newPlayers: Player[], + fixedPairs: Team[], + regenerate: boolean + ) => void; }) { const state = useShufflerState(); const [newPlayer, setNewPlayer] = useState(""); @@ -30,17 +41,24 @@ export function PlayersModal({ const [players, setPlayers] = useState< Array >([]); + const [fixedPairs, setFixedPairs] = useState([]); + + const activePlayers = players.filter((x) => !x.delete); + const activePlayerIds = activePlayers.map(({ id }) => id); + const handleSubmit = (regenerate: boolean = false) => () => { - const newPlayers = players - .filter((x) => !x.delete) + const newPlayers = activePlayers .map(({ id, name }) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name)); - // TODO: error handling for too few players. if (newPlayers.length < 4) return; - onSubmit(newPlayers, regenerate); + + const sanitizedPairs = sanitizeFixedPairs(fixedPairs, activePlayerIds); + const pairsChanged = !pairsEqual(sanitizedPairs, state.fixedPairs); + onSubmit(newPlayers, sanitizedPairs, pairsChanged ? true : regenerate); }; + useEffect(() => { if (open) { const allPlayers = Object.values(state.playersById); @@ -55,8 +73,24 @@ export function PlayersModal({ new: false, })) ); + setFixedPairs(state.fixedPairs); } - }, [open]); + }, [open, state.players, state.playersById, state.fixedPairs]); + + const handlePairChange = (playerId: string, partnerId: string | null) => { + setFixedPairs((pairs) => setPlayerPair(playerId, partnerId, pairs)); + }; + + const handleToggleDelete = (playerId: string) => { + setPlayers((current) => { + const updated = current.map((x) => + x.id === playerId ? { ...x, delete: !x.delete } : x + ); + const remainingIds = updated.filter((x) => !x.delete).map((x) => x.id); + setFixedPairs((pairs) => sanitizeFixedPairs(pairs, remainingIds)); + return updated; + }); + }; return (

- Add or remove players. You can either{" "} + Add or remove players, or link fixed pairs. You can either{" "} redo the current round (because - you haven't played yet) or{" "} + you haven't played yet) or{" "} start a new round with the - updated roster. + updated roster. Changing fixed pairs always redoes the current + round.

{ e.preventDefault(); const playerName = newPlayer.trim(); - // No empty input. if (!playerName) return; - // No duplicate names. if (players.some((player) => player.name === playerName)) return; - // Update list and clear form. setPlayers((players) => [ { name: playerName, id: uuidv4(), delete: false, new: true }, ...players, @@ -120,48 +152,64 @@ export function PlayersModal({
- {players.map((player) => ( -
- - {player.new ? "🆕 " : ""} - {player.delete ? "❌ " : ""} - {player.name} - - - -
- ))} + + {player.new ? "🆕 " : ""} + {player.delete ? "❌ " : ""} + {player.name} + {partnerName && !player.delete ? ( + + + {partnerName} + + ) : null} + + {!player.delete ? ( + ({ + id, + name, + }))} + fixedPairs={fixedPairs} + onPairChange={handlePairChange} + /> + ) : null} + + + + ); + })}