diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..239097c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: yarn + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Test + run: yarn test:ci + + - name: Lint + run: yarn lint + + - name: Build + run: yarn build diff --git a/pages/new.tsx b/pages/new.tsx index 43a6d79..d4eef2f 100644 --- a/pages/new.tsx +++ b/pages/new.tsx @@ -11,6 +11,7 @@ import { useRouter } from "next/router"; import { Fragment, useEffect, useRef, useState } from "react"; import { AddUser, Delete, People, User, Document } from "react-iconly"; import { Court } from "../src/Court"; +import { PairLinkIcon } from "../src/PlayerBadge"; import { newGame, useShufflerDispatch, @@ -19,6 +20,49 @@ import { } from "../src/useShuffler"; import { ResetPlayersModal } from "../src/ResetPlayersModal"; +type NamePair = [string, string]; + +function getPartner(name: string, pairs: NamePair[]): string | null { + for (const [a, b] of pairs) { + if (a === name) return b; + if (b === name) return a; + } + return null; +} + +function isPaired(name: string, pairs: NamePair[]): boolean { + return getPartner(name, pairs) !== null; +} + +function removePairForPlayer(pairs: NamePair[], name: string): NamePair[] { + return pairs.filter(([a, b]) => a !== name && b !== name); +} + +function addPair(pairs: NamePair[], a: string, b: string): NamePair[] { + const sorted: NamePair = a.localeCompare(b) <= 0 ? [a, b] : [b, a]; + return [ + ...removePairForPlayer(pairs, a), + ...removePairForPlayer(pairs, b), + sorted, + ]; +} + +function renameInPairs( + pairs: NamePair[], + oldName: string, + newName: string +): NamePair[] { + return pairs.map(([a, b]) => { + const next: NamePair = [ + a === oldName ? newName : a, + b === oldName ? newName : b, + ]; + return next[0].localeCompare(next[1]) <= 0 + ? next + : [next[1], next[0]]; + }); +} + function NewGame() { const router = useRouter(); const state = useShufflerState(); @@ -36,6 +80,8 @@ function NewGame() { const [courts, setCourts] = useState(state.courts.toString()); const [customizeCourtNames, setCustomizeCourtNames] = useState(false); const [courtNames, setCourtNames] = useState([]); + const [fixedPairs, setFixedPairs] = useState([]); + const [linkingPlayer, setLinkingPlayer] = useState(null); const handleAddPlayers = () => { if (!playerInput) return; @@ -64,7 +110,19 @@ function NewGame() { setCustomizeCourtNames(true); setCourtNames(state.courtNames); } - }, [state.players, state.courts, state.courtNames]); + + if (state.fixedPairs?.length) { + const namePairs = state.fixedPairs + .map(([a, b]) => { + const nameA = playersById[a]?.name; + const nameB = playersById[b]?.name; + if (nameA && nameB) return [nameA, nameB] as NamePair; + return null; + }) + .filter((pair): pair is NamePair => pair !== null); + setFixedPairs(namePairs); + } + }, [state.players, state.courts, state.courtNames, state.fixedPairs, playersById]); const handleNewGame = async () => { const names = players; @@ -89,6 +147,7 @@ function NewGame() { names, courts: courtCount, courtNames: customizeCourtNames ? courtNames : [], + fixedPairs, }); router.push("/rounds"); }; @@ -97,6 +156,33 @@ function NewGame() { setModal("reset-players"); }; + const handleLinkClick = (name: string) => { + if (isPaired(name, fixedPairs)) return; + + if (linkingPlayer === null) { + setLinkingPlayer(name); + return; + } + + if (linkingPlayer === name) { + setLinkingPlayer(null); + return; + } + + if (isPaired(linkingPlayer, fixedPairs)) { + setLinkingPlayer(name); + return; + } + + setFixedPairs(addPair(fixedPairs, linkingPlayer, name)); + setLinkingPlayer(null); + }; + + const handleUnlink = (name: string) => { + setFixedPairs(removePairForPlayer(fixedPairs, name)); + if (linkingPlayer === name) setLinkingPlayer(null); + }; + const playerError = formStatus === "validating" && players.length < 4; const courtsError = formStatus === "validating" && @@ -120,6 +206,8 @@ function NewGame() { onClose={() => setModal("none")} onSubmit={() => { setPlayers([]); + setFixedPairs([]); + setLinkingPlayer(null); setModal("none"); }} /> @@ -179,48 +267,114 @@ function NewGame() { - {players.map((name, index) => ( - -
- - - {index + 1} - - { - const newName = e.currentTarget.value; - setPlayers([ - ...players.slice(0, index), - newName, - ...players.slice(index + 1), - ]); - }} - fullWidth - /> - -
-
- ))} + + + {index + 1} + + { + const newName = e.currentTarget.value; + setFixedPairs( + renameInPairs(fixedPairs, name, newName) + ); + if (linkingPlayer === name) setLinkingPlayer(newName); + setPlayers([ + ...players.slice(0, index), + newName, + ...players.slice(index + 1), + ]); + }} + fullWidth + /> + {paired ? ( + <> + + ↔ {partner} + + + + ) : ( + + )} + + + + ); + })} + {linkingPlayer && !isPaired(linkingPlayer, fixedPairs) && ( + <> + +

+ Tap another player to pair with {linkingPlayer}, or tap{" "} + {linkingPlayer}'s link button again to cancel. +

+ + )}