From b928d85e98456d06c1780d052e3793da75f7a49c Mon Sep 17 00:00:00 2001 From: javaisbetterthanpython Date: Tue, 9 Jun 2026 09:01:18 -0400 Subject: [PATCH 1/3] [#31] Fix player name editing regressions and harden state handling - Filter stale player IDs from cache and setup page load - Normalize volunteerSitoutsByRound for corrupt/missing cache entries - Extend pickleball adjective list to 30 and randomize assignment - Prevent adjective reuse across different duplicate base names - Add compact click-to-edit mode for /new setup player list - Improve player row layout with proper overflow handling - Defensive null checks for volunteerSitoutsByRound access - Disable PWA service worker in development to prevent HMR issues - Add volunteerSitouts normalization tests Closes #31 Co-authored-by: Cursor --- next.config.js | 2 + pages/new.tsx | 158 +++++++++++++++++++--------------- pages/rounds.tsx | 2 +- src/Layout.tsx | 10 ++- src/PlayerNameEdit.tsx | 54 +++++++++++- src/SitoutsModal.tsx | 12 +-- src/playerNames.ts | 69 +++++++++++++-- src/useShuffler.tsx | 45 +++++++--- test/playerNames.spec.ts | 48 +++++++++-- test/volunteerSitouts.spec.ts | 22 +++++ 10 files changed, 310 insertions(+), 112 deletions(-) create mode 100644 test/volunteerSitouts.spec.ts diff --git a/next.config.js b/next.config.js index 0dfb504..0907ad1 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,8 @@ /** @type {import('next').NextConfig} */ const withPwa = require("next-pwa")({ dest: "public", + // Stale SW caches break dev HMR and cause blank pages after rebuilds. + disable: process.env.NODE_ENV === "development", }); const nextConfig = withPwa({ reactStrictMode: true, diff --git a/pages/new.tsx b/pages/new.tsx index 17cda87..536cad1 100644 --- a/pages/new.tsx +++ b/pages/new.tsx @@ -22,6 +22,7 @@ import { ResetPlayersModal } from "../src/ResetPlayersModal"; import { PlayerNameEdit } from "../src/PlayerNameEdit"; import { disambiguateNames, renameWithDisambiguation } from "../src/playerNames"; import { v4 as uuidv4 } from "uuid"; +import clsx from "clsx"; type NamePair = [string, string]; type SetupPlayer = { id: string; name: string }; @@ -123,6 +124,7 @@ function NewGame() { // Load last time's players and court names. useEffect(() => { const loaded = [...state.players] + .filter((id) => playersById[id]) .map((id) => ({ id, name: playersById[id].name, @@ -300,96 +302,110 @@ function NewGame() { return (
- - + + + + {index + 1} - { - const namesById = renameWithDisambiguation( - players.map((p) => ({ id: p.id, name: p.name })), - id, - newName - ); - const oldName = name; - const nextPlayers = players.map((p) => ({ - ...p, - name: namesById[p.id] ?? p.name, - })); - setFixedPairs( - renameInPairs(fixedPairs, oldName, namesById[id] ?? newName) - ); - if (linkingPlayer === oldName) { - setLinkingPlayer(namesById[id] ?? newName); - } - setPlayers(nextPlayers); - }} - /> - {paired ? ( - <> +
+ { + const namesById = renameWithDisambiguation( + players.map((p) => ({ id: p.id, name: p.name })), + id, + newName + ); + const oldName = name; + const nextPlayers = players.map((p) => ({ + ...p, + name: namesById[p.id] ?? p.name, + })); + setFixedPairs( + renameInPairs( + fixedPairs, + oldName, + namesById[id] ?? newName + ) + ); + if (linkingPlayer === oldName) { + setLinkingPlayer(namesById[id] ?? newName); + } + setPlayers(nextPlayers); + }} + /> + {paired ? ( - ↔ {partner} + + {partner} + ) : null} +
+
+ {paired ? ( - - ) : ( + ) : ( + + )} - )} - +
); diff --git a/pages/rounds.tsx b/pages/rounds.tsx index 4d5fac0..364e54e 100644 --- a/pages/rounds.tsx +++ b/pages/rounds.tsx @@ -60,7 +60,7 @@ export default function Rounds() { Math.min(roundIndex, Math.max(state.rounds.length - 1, 0)) ); const round = state.rounds[displayIndex]; - const volunteers = state.volunteerSitoutsByRound[displayIndex]; + const volunteers = state.volunteerSitoutsByRound?.[displayIndex] ?? []; const { sitOuts = [], matches = [] } = round || {}; const isHistoricalRound = displayIndex < state.rounds.length - 1; diff --git a/src/Layout.tsx b/src/Layout.tsx index 8c7e675..0ce1276 100644 --- a/src/Layout.tsx +++ b/src/Layout.tsx @@ -25,9 +25,13 @@ const grandstander = Grandstander({ subsets: ["latin"] }); export function Layout({ children }: { children: React.ReactNode }) { useEffect(() => { - new CircleType(document.getElementById("jumbled")) - .radius(200) - .forceHeight(false); + const el = document.getElementById("jumbled"); + if (!el) return; + try { + new CircleType(el).radius(200).forceHeight(false); + } catch { + // Non-fatal: logo still renders without curved text. + } }, []); const router = useRouter(); useLoadState(); diff --git a/src/PlayerNameEdit.tsx b/src/PlayerNameEdit.tsx index 2b90065..790ffff 100644 --- a/src/PlayerNameEdit.tsx +++ b/src/PlayerNameEdit.tsx @@ -8,12 +8,18 @@ export function PlayerNameEdit({ onSave, className, disabled = false, + compact = false, + editTrigger = "icon", "aria-label": ariaLabel = "Player name", }: { name: string; onSave: (newName: string) => void; className?: string; disabled?: boolean; + /** When true, name does not grow to fill the row (for setup lists). */ + compact?: boolean; + /** `icon` — pencil on the left; `click` — tap the name to edit (no pencil). */ + editTrigger?: "icon" | "click"; "aria-label"?: string; }) { const [editing, setEditing] = useState(false); @@ -74,20 +80,64 @@ export function PlayerNameEdit({ ); } + if (editTrigger === "click") { + if (disabled) { + return ( + + {name} + + ); + } + return ( + + ); + } + return ( -
- {name} +
{!disabled ? ( ) : null} + + {name} +
); } diff --git a/src/SitoutsModal.tsx b/src/SitoutsModal.tsx index 2759b96..364cdbc 100644 --- a/src/SitoutsModal.tsx +++ b/src/SitoutsModal.tsx @@ -49,11 +49,13 @@ export function SitoutsModal({ value={volunteers} onValueChange={setVolunteers} > - {state.players.map((player) => ( - - {state.playersById[player].name} - - ))} + {state.players + .filter((player) => state.playersById[player]) + .map((player) => ( + + {state.playersById[player].name} + + ))} diff --git a/src/playerNames.ts b/src/playerNames.ts index fac3104..999a48e 100644 --- a/src/playerNames.ts +++ b/src/playerNames.ts @@ -12,6 +12,26 @@ export const PICKLEBALL_ADJECTIVES = [ "Volley", "Smash", "Rally", + "Drop", + "Poach", + "Banger", + "Flick", + "Slice", + "Drive", + "Baseline", + "Cross", + "Golden", + "Sneaky", + "Sharp", + "Quick", + "Soft", + "Power", + "Angle", + "Net", + "Spinny", + "Third", + "Reset", + "ATP", ] as const; const NUMBER_SUFFIX_RE = /^(.+) \((\d+)\)$/; @@ -40,19 +60,45 @@ function isPlainBaseName(name: string, base: string): boolean { return name === base; } +function shuffleArray(items: T[]): T[] { + const arr = [...items]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +function collectUsedAdjectives( + players: Array<{ id: string; name: string }> +): Set { + const used = new Set(); + for (const player of players) { + const adj = getPickleballAdjective(player.name); + if (adj) used.add(adj); + } + return used; +} + function assignAdjectives( members: Array<{ id: string; name: string }>, - base: string + base: string, + usedAdjectives: Set ): Map { const result = new Map(); const sorted = [...members].sort((a, b) => a.id.localeCompare(b.id)); - let adjIndex = 0; - for (const member of sorted) { - const adj = - PICKLEBALL_ADJECTIVES[adjIndex % PICKLEBALL_ADJECTIVES.length]; - adjIndex += 1; - result.set(member.id, `${adj} ${base}`); - } + const available = shuffleArray( + PICKLEBALL_ADJECTIVES.filter((adj) => !usedAdjectives.has(adj)) + ); + sorted.forEach((member, index) => { + const adj = available[index]; + if (adj) { + result.set(member.id, `${adj} ${base}`); + usedAdjectives.add(adj); + } else { + result.set(member.id, index === 0 ? base : `${base} (${index + 1})`); + } + }); return result; } @@ -86,6 +132,7 @@ export function disambiguateNames( before?: Array<{ id: string; name: string }> ): Map { const result = new Map(); + const usedAdjectives = collectUsedAdjectives(players); const byBase = new Map>(); for (const player of players) { @@ -124,7 +171,11 @@ export function disambiguateNames( } if (plainMembers.length >= 2) { - const adjectiveAssignments = assignAdjectives(plainMembers, base); + const adjectiveAssignments = assignAdjectives( + plainMembers, + base, + usedAdjectives + ); for (const member of members) { if (adjectiveAssignments.has(member.id)) { result.set(member.id, adjectiveAssignments.get(member.id)!); diff --git a/src/useShuffler.tsx b/src/useShuffler.tsx index 3e5d65e..1f4e898 100644 --- a/src/useShuffler.tsx +++ b/src/useShuffler.tsx @@ -121,12 +121,25 @@ function getPlayersById(previous: Record, players: Player[]) { return byId; } +/** Ensure one volunteer-sitout list per round (older caches may omit or truncate this). */ +export function normalizeVolunteerSitoutsByRound( + roundsLength: number, + volunteerSitoutsByRound: unknown +): PlayerId[][] { + const source = Array.isArray(volunteerSitoutsByRound) + ? volunteerSitoutsByRound + : []; + return Array.from({ length: roundsLength }, (_, i) => + Array.isArray(source[i]) ? source[i] : [] + ); +} + function loadFromCache(previousState: State): State { const existingState = previousState || defaultState; if (typeof window === "undefined") return existingState; const storageState = window.localStorage.getItem("state"); if (storageState === null) { - return existingState; + return { ...existingState, cacheLoaded: true }; } try { const { @@ -144,12 +157,19 @@ function loadFromCache(previousState: State): State { isNaN(courts) || !playersById ) - return existingState; + return { ...existingState, cacheLoaded: true }; + + const validPlayers = players.filter( + (id: PlayerId) => playersById[id]?.name != null + ); return { - players, + players: validPlayers, playersById, - volunteerSitoutsByRound, + volunteerSitoutsByRound: normalizeVolunteerSitoutsByRound( + rounds.length, + volunteerSitoutsByRound + ), rounds, courts, courtNames, @@ -158,7 +178,7 @@ function loadFromCache(previousState: State): State { generating: false, }; } catch (e) { - return existingState; + return { ...existingState, cacheLoaded: true }; } } @@ -244,15 +264,16 @@ function shufflerReducer(state: State, action: Action): State { const rounds = regenerate ? [...state.rounds.slice(0, -1), round] : [...state.rounds, round]; + const priorVolunteers = normalizeVolunteerSitoutsByRound( + state.rounds.length, + state.volunteerSitoutsByRound + ); const volunteerSitoutsByRound = regenerate ? [ - ...state.volunteerSitoutsByRound.slice(0, -1), + ...priorVolunteers.slice(0, -1), action.payload.volunteerSitouts, ] - : [ - ...state.volunteerSitoutsByRound, - action.payload.volunteerSitouts, - ]; + : [...priorVolunteers, action.payload.volunteerSitouts]; return cacheState({ ...state, generating: false, @@ -417,7 +438,7 @@ async function editCourts( if (state.generating) return; const { courts, regenerate } = payload; const volunteerSitouts = regenerate - ? state.volunteerSitoutsByRound.slice(-1)[0] + ? ((state.volunteerSitoutsByRound ?? []).slice(-1)[0] ?? []) : []; const rounds = regenerate ? state.rounds.slice(0, -1) : state.rounds; dispatch({ @@ -476,7 +497,7 @@ async function editPlayers( if (state.generating) return; const { newPlayers, fixedPairs, regenerate } = payload; const volunteerSitouts = regenerate - ? state.volunteerSitoutsByRound.slice(-1)[0] + ? ((state.volunteerSitoutsByRound ?? []).slice(-1)[0] ?? []) : []; const rounds = regenerate ? state.rounds.slice(0, -1) : state.rounds; diff --git a/test/playerNames.spec.ts b/test/playerNames.spec.ts index 41349e0..ceabd9c 100644 --- a/test/playerNames.spec.ts +++ b/test/playerNames.spec.ts @@ -1,6 +1,8 @@ import { disambiguateNames, getBaseName, + getPickleballAdjective, + PICKLEBALL_ADJECTIVES, renameInNameList, renameWithDisambiguation, } from "../src/playerNames"; @@ -21,6 +23,12 @@ describe("getBaseName", () => { }); }); +describe("PICKLEBALL_ADJECTIVES", () => { + it("has at least 30 options for a session", () => { + expect(PICKLEBALL_ADJECTIVES.length).toBeGreaterThanOrEqual(30); + }); +}); + describe("disambiguateNames", () => { it("leaves a single player as plain base name", () => { const result = disambiguateNames([{ id: "a", name: "Bob" }]); @@ -37,8 +45,10 @@ describe("disambiguateNames", () => { { id: "a", name: "Bob" }, { id: "b", name: "Bob" }, ]); - expect(result.get("a")).toMatch(/^(Pickle|Dink|Kitchen) Bob$/); - expect(result.get("b")).toMatch(/^(Pickle|Dink|Kitchen) Bob$/); + const names = [result.get("a")!, result.get("b")!]; + const adjectives = names.map((n) => getPickleballAdjective(n)); + expect(adjectives.every(Boolean)).toBe(true); + expect(new Set(adjectives).size).toBe(2); expect(result.get("a")).not.toBe(result.get("b")); }); @@ -72,10 +82,24 @@ describe("disambiguateNames", () => { { id: "c", name: "Bob" }, ]); const names = [...result.values()]; - expect( - names.every((n) => /^(Pickle|Dink|Kitchen|Paddle|Ernie) Bob$/.test(n)) - ).toBe(true); + const adjectives = names.map((n) => getPickleballAdjective(n)); + expect(adjectives.every(Boolean)).toBe(true); expect(new Set(names).size).toBe(3); + expect(new Set(adjectives).size).toBe(3); + }); + + it("does not reuse adjectives across different duplicate base names", () => { + const result = disambiguateNames([ + { id: "a", name: "Alex" }, + { id: "b", name: "Alex" }, + { id: "c", name: "Andrew" }, + { id: "d", name: "Andrew" }, + ]); + const adjectives = [...result.values()] + .map((n) => getPickleballAdjective(n)) + .filter(Boolean); + expect(adjectives).toHaveLength(4); + expect(new Set(adjectives).size).toBe(4); }); }); @@ -89,8 +113,11 @@ describe("renameWithDisambiguation", () => { "b", "Alice" ); - expect(names.a).toMatch(/^(Pickle|Dink|Kitchen) Alice$/); - expect(names.b).toMatch(/^(Pickle|Dink|Kitchen) Alice$/); + expect(getPickleballAdjective(names.a)).toBeTruthy(); + expect(getPickleballAdjective(names.b)).toBeTruthy(); + expect(getPickleballAdjective(names.a)).not.toBe( + getPickleballAdjective(names.b) + ); expect(names.a).not.toBe(names.b); }); }); @@ -98,7 +125,10 @@ describe("renameWithDisambiguation", () => { describe("renameInNameList", () => { it("assigns adjectives when adding a duplicate via rename", () => { const result = renameInNameList(["Alice", "Bob"], 1, "Alice"); - expect(result[0]).toMatch(/^(Pickle|Dink|Kitchen) Alice$/); - expect(result[1]).toMatch(/^(Pickle|Dink|Kitchen) Alice$/); + expect(getPickleballAdjective(result[0])).toBeTruthy(); + expect(getPickleballAdjective(result[1])).toBeTruthy(); + expect(getPickleballAdjective(result[0])).not.toBe( + getPickleballAdjective(result[1]) + ); }); }); diff --git a/test/volunteerSitouts.spec.ts b/test/volunteerSitouts.spec.ts new file mode 100644 index 0000000..b73db62 --- /dev/null +++ b/test/volunteerSitouts.spec.ts @@ -0,0 +1,22 @@ +import { normalizeVolunteerSitoutsByRound } from "../src/useShuffler"; + +describe("normalizeVolunteerSitoutsByRound", () => { + test("fills missing entries with empty arrays", () => { + expect(normalizeVolunteerSitoutsByRound(2, undefined)).toEqual([[], []]); + expect(normalizeVolunteerSitoutsByRound(2, null)).toEqual([[], []]); + expect(normalizeVolunteerSitoutsByRound(2, [])).toEqual([[], []]); + expect(normalizeVolunteerSitoutsByRound(2, [["a"]])).toEqual([["a"], []]); + }); + + test("truncates extra entries", () => { + expect( + normalizeVolunteerSitoutsByRound(1, [["a"], ["b", "c"]]) + ).toEqual([["a"]]); + }); + + test("replaces non-array slots", () => { + expect( + normalizeVolunteerSitoutsByRound(2, [["a"], null, "bad"]) + ).toEqual([["a"], []]); + }); +}); From ef6c11d879affc41c0398adf25fe6af2da9604d3 Mon Sep 17 00:00:00 2001 From: javaisbetterthanpython Date: Tue, 9 Jun 2026 12:56:44 -0400 Subject: [PATCH 2/3] Switch PlayersModal to click-to-edit and fix worker hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change PlayersModal PlayerNameEdit to editTrigger="click" (tap name to edit, no pencil icon), matching the /new setup page behavior - Add try/catch in worker.ts so getNextBestRound errors are reported back to the main thread instead of silently swallowing them - Add timeout (30s), proper listener cleanup, and settled guard to generateRound so the UI never gets permanently stuck on "Jumbling…" Co-authored-by: Cursor --- src/PlayersModal.tsx | 3 ++- src/matching/worker.ts | 24 ++++++++++++++---------- src/useShuffler.tsx | 34 +++++++++++++++++++++++++++++----- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/PlayersModal.tsx b/src/PlayersModal.tsx index fed3cc2..4fbbc9c 100644 --- a/src/PlayersModal.tsx +++ b/src/PlayersModal.tsx @@ -140,7 +140,7 @@ export function PlayersModal({

- Add or remove players, link fixed pairs, or tap the pencil to rename. + Add or remove players, link fixed pairs, or tap a name to rename. Renames apply immediately. For roster changes, either{" "} redo the current round (because you haven't played yet) or{" "} @@ -210,6 +210,7 @@ export function PlayersModal({ {player.delete ? "❌ " : ""} handleRename(player.id, newName)} /> diff --git a/src/matching/worker.ts b/src/matching/worker.ts index d257370..ab888b8 100644 --- a/src/matching/worker.ts +++ b/src/matching/worker.ts @@ -7,15 +7,19 @@ addEventListener( [Round[], PlayerId[], number, PlayerId[], Team[]?] > ) => { - const [rounds, players, courts, volunteerSitouts, fixedPairs = []] = - event.data; - const round = await getNextBestRound( - rounds, - players, - courts, - volunteerSitouts, - fixedPairs - ); - postMessage(round); + try { + const [rounds, players, courts, volunteerSitouts, fixedPairs = []] = + event.data; + const round = await getNextBestRound( + rounds, + players, + courts, + volunteerSitouts, + fixedPairs + ); + postMessage(round); + } catch (err) { + postMessage({ error: err instanceof Error ? err.message : String(err) }); + } } ); diff --git a/src/useShuffler.tsx b/src/useShuffler.tsx index 1f4e898..630121f 100644 --- a/src/useShuffler.tsx +++ b/src/useShuffler.tsx @@ -403,6 +403,8 @@ async function newGame( } } +const GENERATION_TIMEOUT_MS = 30_000; + async function generateRound( worker: Worker, rounds: Round[], @@ -412,18 +414,40 @@ async function generateRound( fixedPairs: Team[] = [] ): Promise { return new Promise((resolve, reject) => { - const messageCallback = (event: MessageEvent) => { - resolve(event.data); + let settled = false; + const cleanup = () => { worker.removeEventListener("message", messageCallback); + worker.removeEventListener("error", errorCallback); + clearTimeout(timer); + }; + + const messageCallback = (event: MessageEvent) => { + if (settled) return; + settled = true; + cleanup(); + if (event.data?.error) { + reject(new Error(event.data.error)); + } else { + resolve(event.data); + } }; - worker.addEventListener("message", messageCallback); const errorCallback = (error: ErrorEvent) => { + if (settled) return; + settled = true; + cleanup(); reject(error); - worker.removeEventListener("error", errorCallback); }; - worker.addEventListener("error", errorCallback); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Round generation timed out")); + }, GENERATION_TIMEOUT_MS); + + worker.addEventListener("message", messageCallback); + worker.addEventListener("error", errorCallback); worker.postMessage([rounds, players, courts, volunteerSitouts, fixedPairs]); }); } From 6d5911f0b2528b405b5732049d57d43412e408b0 Mon Sep 17 00:00:00 2001 From: javaisbetterthanpython Date: Tue, 9 Jun 2026 13:12:51 -0400 Subject: [PATCH 3/3] Replace deprecated onClick with onPress on NextUI Buttons Co-authored-by: Cursor --- pages/new.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pages/new.tsx b/pages/new.tsx index 536cad1..a916cf7 100644 --- a/pages/new.tsx +++ b/pages/new.tsx @@ -254,7 +254,7 @@ function NewGame() { size="sm" color="secondary" variant="flat" - onClick={() => handleResetPlayers()} + onPress={() => handleResetPlayers()} > Reset players @@ -288,7 +288,7 @@ function NewGame() { aria-label="Add players in text box" isIconOnly type="button" - onClick={() => handleAddPlayers()} + onPress={() => handleAddPlayers()} > @@ -484,7 +484,7 @@ function NewGame() { size="sm" color="secondary" variant="flat" - onClick={() => + onPress={() => setCourtNames( Array.from( new Array(Math.max(parseInt(courts) || 0, 0)), @@ -500,7 +500,7 @@ function NewGame() { size="sm" color="primary" variant="flat" - onClick={() => + onPress={() => setCourtNames( Array.from( new Array(Math.max(parseInt(courts) || 0, 0)), @@ -516,7 +516,7 @@ function NewGame() { size="sm" color="secondary" variant="flat" - onClick={() => + onPress={() => setCourtNames( Array.from( new Array(Math.max(parseInt(courts) || 0, 0)),