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..a916cf7 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,
@@ -252,7 +254,7 @@ function NewGame() {
size="sm"
color="secondary"
variant="flat"
- onClick={() => handleResetPlayers()}
+ onPress={() => handleResetPlayers()}
>
Reset players
@@ -286,7 +288,7 @@ function NewGame() {
aria-label="Add players in text box"
isIconOnly
type="button"
- onClick={() => handleAddPlayers()}
+ onPress={() => handleAddPlayers()}
>
@@ -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 ? (
- >
- ) : (
+ ) : (
+
+ )}
- )}
-
+
);
@@ -468,7 +484,7 @@ function NewGame() {
size="sm"
color="secondary"
variant="flat"
- onClick={() =>
+ onPress={() =>
setCourtNames(
Array.from(
new Array(Math.max(parseInt(courts) || 0, 0)),
@@ -484,7 +500,7 @@ function NewGame() {
size="sm"
color="primary"
variant="flat"
- onClick={() =>
+ onPress={() =>
setCourtNames(
Array.from(
new Array(Math.max(parseInt(courts) || 0, 0)),
@@ -500,7 +516,7 @@ function NewGame() {
size="sm"
color="secondary"
variant="flat"
- onClick={() =>
+ onPress={() =>
setCourtNames(
Array.from(
new Array(Math.max(parseInt(courts) || 0, 0)),
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/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/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/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/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..630121f 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,
@@ -382,6 +403,8 @@ async function newGame(
}
}
+const GENERATION_TIMEOUT_MS = 30_000;
+
async function generateRound(
worker: Worker,
rounds: Round[],
@@ -391,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]);
});
}
@@ -417,7 +462,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 +521,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"], []]);
+ });
+});