diff --git a/pages/new.tsx b/pages/new.tsx index 17cda87..37c8795 100644 --- a/pages/new.tsx +++ b/pages/new.tsx @@ -21,6 +21,16 @@ import { import { ResetPlayersModal } from "../src/ResetPlayersModal"; import { PlayerNameEdit } from "../src/PlayerNameEdit"; import { disambiguateNames, renameWithDisambiguation } from "../src/playerNames"; +import { GroupsEditor } from "../src/GroupsEditor"; +import { + assignNewPlayersToStandard, + defaultGroupsState, + ensurePairInHighestGroup, + GroupsState, + levelFixedPairsForGroups, + normalizeGroupsState, + sanitizePlayerGroups, +} from "../src/groups"; import { v4 as uuidv4 } from "uuid"; type NamePair = [string, string]; @@ -86,6 +96,9 @@ function NewGame() { const [courtNames, setCourtNames] = useState([]); const [fixedPairs, setFixedPairs] = useState([]); const [linkingPlayer, setLinkingPlayer] = useState(null); + const [groupsState, setGroupsState] = useState( + defaultGroupsState() + ); const applySetupDisambiguation = ( roster: SetupPlayer[], @@ -114,7 +127,16 @@ function NewGame() { setPlayers((current) => { const before = current; const added = names.map((name) => ({ id: uuidv4(), name })); - return applySetupDisambiguation([...current, ...added], before); + const next = applySetupDisambiguation([...current, ...added], before); + if (groupsState.enabled) { + setGroupsState((gs) => + assignNewPlayersToStandard( + gs, + added.map((p) => p.id) + ) + ); + } + return next; }); setPlayerInput(""); playerInputRef.current?.focus(); @@ -147,7 +169,16 @@ function NewGame() { .filter((pair): pair is NamePair => pair !== null); setFixedPairs(namePairs); } - }, [state.players, state.courts, state.courtNames, state.fixedPairs, playersById]); + + setGroupsState(normalizeGroupsState(state.groups)); + }, [ + state.players, + state.courts, + state.courtNames, + state.fixedPairs, + state.groups, + playersById, + ]); const handleNewGame = async () => { const names = players.map((p) => p.name); @@ -168,11 +199,23 @@ function NewGame() { setFormStatus("validating"); return; } + const playerIds = players.map((p) => p.id); + const nameToId = Object.fromEntries(players.map((p) => [p.name, p.id])); + const pairTeams = fixedPairs + .map(([a, b]) => [nameToId[a], nameToId[b]] as [string, string]) + .filter(([a, b]) => a && b); + let setupGroups = groupsState; + if (setupGroups.enabled) { + setupGroups = sanitizePlayerGroups(setupGroups, playerIds); + setupGroups = levelFixedPairsForGroups(pairTeams, setupGroups); + } + await newGame(dispatch, state, worker, { names, courts: courtCount, courtNames: customizeCourtNames ? courtNames : [], fixedPairs, + groups: setupGroups, }); router.push("/rounds"); }; @@ -200,6 +243,13 @@ function NewGame() { } setFixedPairs(addPair(fixedPairs, linkingPlayer, name)); + if (groupsState.enabled) { + const idA = players.find((p) => p.name === linkingPlayer)?.id; + const idB = players.find((p) => p.name === name)?.id; + if (idA && idB) { + setGroupsState((gs) => ensurePairInHighestGroup(idA, idB, gs)); + } + } setLinkingPlayer(null); }; @@ -553,6 +603,14 @@ function NewGame() { )} + + + ) : ( + + + + ))} + +
+ setNewGroupName(e.target.value)} + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddGroup(); + } + }} + /> + +
+ + +
+

Player groups

+ {players.length === 0 ? ( +

Add players first.

+ ) : ( +
+ {players.map((player) => { + const swing = isSwingPlayer(player.id, groupsState); + return ( +
+ + {swing ? ( + ⚠️ + ) : null} + {player.name} + + {groupsState.groups.map((group) => { + const checked = ( + groupsState.playerGroups[player.id] ?? [] + ).includes(group.id); + return ( + + onChange( + togglePlayerGroup( + groupsState, + player.id, + group.id + ) + ) + } + > + {group.name} + + ); + })} +
+ ); + })} +
+ )} +
+ + ) : null} + + ); +} diff --git a/src/GroupsModal.tsx b/src/GroupsModal.tsx new file mode 100644 index 0000000..afcde34 --- /dev/null +++ b/src/GroupsModal.tsx @@ -0,0 +1,127 @@ +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Select, + SelectItem, +} from "@nextui-org/react"; +import { useEffect, useState } from "react"; +import { GroupsEditor } from "./GroupsEditor"; +import { + allPlayersHaveGroup, + GroupsState, + GroupPlayMode, + levelFixedPairsForGroups, + normalizeGroupsState, + sanitizePlayerGroups, +} from "./groups"; +import { useShufflerState } from "./useShuffler"; + +export function GroupsModal({ + open, + onClose, + onSubmit, +}: { + open: boolean; + onClose: () => void; + onSubmit: (groups: GroupsState) => void; +}) { + const state = useShufflerState(); + const [groupsState, setGroupsState] = useState( + defaultFromState(state.groups) + ); + + const players = state.players + .map((id) => ({ + id, + name: state.playersById[id]?.name ?? "", + })) + .filter((p) => p.name) + .sort((a, b) => a.name.localeCompare(b.name)); + + useEffect(() => { + if (open) { + setGroupsState(defaultFromState(state.groups)); + } + }, [open, state.groups]); + + const handleSubmit = () => { + let next = groupsState; + if (next.enabled) { + next = sanitizePlayerGroups(next, state.players); + next = levelFixedPairsForGroups(state.fixedPairs, next); + } + if (!allPlayersHaveGroup(next, state.players)) return; + onSubmit(next); + }; + + return ( + + + +

Skill groups

+
+ + + {groupsState.enabled ? ( + + ) : null} + + + + + +
+
+ ); +} + +function defaultFromState(groups: GroupsState | undefined): GroupsState { + return normalizeGroupsState(groups); +} diff --git a/src/PlayerBadge.tsx b/src/PlayerBadge.tsx index 70c545e..83d4c6c 100644 --- a/src/PlayerBadge.tsx +++ b/src/PlayerBadge.tsx @@ -2,6 +2,7 @@ import React from "react"; import clsx from "clsx"; import { PlayerId } from "./matching/heuristics"; import { getPartnerName, useFixedPairs } from "./fixedPairs"; +import { isSwingPlayer, useGroups } from "./groups"; import { useShufflerState } from "./useShuffler"; export function PairLinkIcon({ @@ -54,10 +55,13 @@ export function PlayerBadge({ }) { const fixedPairs = useFixedPairs(); const state = useShufflerState(); + const groups = useGroups(); const partnerName = playerId && fixedPairs.length ? getPartnerName(playerId, fixedPairs, state.playersById) : undefined; + const swing = + playerId && groups.enabled && isSwingPlayer(playerId, groups); return (

+ {swing ? ( + ⚠️ + ) : null} {partnerName ? ( ) : null} diff --git a/src/PlayersModal.tsx b/src/PlayersModal.tsx index fed3cc2..179f4bf 100644 --- a/src/PlayersModal.tsx +++ b/src/PlayersModal.tsx @@ -28,6 +28,15 @@ import { import { PlayerPairSelect } from "./PlayerPairSelect"; import { PlayerNameEdit } from "./PlayerNameEdit"; import { disambiguateNames } from "./playerNames"; +import { GroupsEditor } from "./GroupsEditor"; +import { + allPlayersHaveGroup, + ensurePairInHighestGroup, + GroupsState, + levelFixedPairsForGroups, + normalizeGroupsState, + sanitizePlayerGroups, +} from "./groups"; export function PlayersModal({ open, @@ -39,6 +48,7 @@ export function PlayersModal({ onSubmit: ( newPlayers: Player[], fixedPairs: Team[], + groups: GroupsState, regenerate: boolean ) => void; }) { @@ -50,6 +60,9 @@ export function PlayersModal({ Array >([]); const [fixedPairs, setFixedPairs] = useState([]); + const [groupsState, setGroupsState] = useState( + normalizeGroupsState(state.groups) + ); const activePlayers = players.filter((x) => !x.delete); const activePlayerIds = activePlayers.map(({ id }) => id); @@ -78,7 +91,18 @@ export function PlayersModal({ const sanitizedPairs = sanitizeFixedPairs(fixedPairs, activePlayerIds); const pairsChanged = !pairsEqual(sanitizedPairs, state.fixedPairs); - onSubmit(newPlayers, sanitizedPairs, pairsChanged ? true : regenerate); + let nextGroups = groupsState; + if (nextGroups.enabled) { + nextGroups = sanitizePlayerGroups(nextGroups, activePlayerIds); + nextGroups = levelFixedPairsForGroups(sanitizedPairs, nextGroups); + if (!allPlayersHaveGroup(nextGroups, activePlayerIds)) return; + } + onSubmit( + newPlayers, + sanitizedPairs, + nextGroups, + pairsChanged ? true : regenerate + ); }; useEffect(() => { @@ -96,11 +120,17 @@ export function PlayersModal({ })) ); setFixedPairs(state.fixedPairs); + setGroupsState(normalizeGroupsState(state.groups)); } - }, [open, state.players, state.playersById, state.fixedPairs]); + }, [open, state.players, state.playersById, state.fixedPairs, state.groups]); const handlePairChange = (playerId: string, partnerId: string | null) => { setFixedPairs((pairs) => setPlayerPair(playerId, partnerId, pairs)); + if (groupsState.enabled && partnerId) { + setGroupsState((current) => + ensurePairInHighestGroup(playerId, partnerId, current) + ); + } }; const handleToggleDelete = (playerId: string) => { @@ -251,6 +281,13 @@ export function PlayersModal({ ); })} + ({ id, name }))} + showEnableToggle + midSessionNote="Changes apply from the next round onward. Past rounds are unchanged." + />