aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/components/AdminPanel.svelte33
-rw-r--r--src/lib/components/CourtList.svelte14
-rw-r--r--src/lib/components/GameCard.svelte17
-rw-r--r--src/lib/components/GameCard.test.js32
-rw-r--r--src/lib/components/GamesSection.svelte11
-rw-r--r--src/lib/components/GuestList.svelte14
-rw-r--r--src/lib/components/ProposeConfirmControls.svelte8
-rw-r--r--src/lib/components/RegularList.svelte14
-rw-r--r--src/lib/components/SkillFactorSelect.svelte13
-rw-r--r--src/lib/components/TeamDisplay.svelte7
-rw-r--r--src/lib/components/TimerControls.svelte39
-rw-r--r--src/lib/components/TimerControls.test.js35
-rw-r--r--src/lib/components/ToggleButton.svelte7
-rw-r--r--src/lib/components/ToggleButton.test.js31
-rw-r--r--src/lib/logic/csv.js23
-rw-r--r--src/lib/logic/csv.test.js27
-rw-r--r--src/lib/logic/matchmaking.js113
-rw-r--r--src/lib/logic/matchmaking.test.js188
-rw-r--r--src/lib/logic/normalize.js6
-rw-r--r--src/lib/logic/normalize.test.js28
-rw-r--r--src/lib/logic/players.js73
-rw-r--r--src/lib/logic/random.js8
-rw-r--r--src/lib/stores/courts.svelte.js39
-rw-r--r--src/lib/stores/games.svelte.js91
-rw-r--r--src/lib/stores/guests.svelte.js46
-rw-r--r--src/lib/stores/regulars.svelte.js79
-rw-r--r--src/lib/stores/skillFactor.svelte.js9
-rw-r--r--src/lib/stores/timer.svelte.js106
-rw-r--r--src/lib/test-setup.js7
29 files changed, 1118 insertions, 0 deletions
diff --git a/src/lib/components/AdminPanel.svelte b/src/lib/components/AdminPanel.svelte
new file mode 100644
index 0000000..2442386
--- /dev/null
+++ b/src/lib/components/AdminPanel.svelte
@@ -0,0 +1,33 @@
+<script>
+ import { resetSession } from "../stores/games.svelte.js";
+ import { loadRegularsFromCsv } from "../stores/regulars.svelte.js";
+
+ let fileInput;
+
+ function triggerUpload() {
+ fileInput.click();
+ }
+
+ function handleFileChange(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ loadRegularsFromCsv(reader.result);
+ };
+ reader.readAsText(file);
+ }
+</script>
+
+<div class="grid">
+ <button onclick={resetSession}>Reset Session</button>
+ <button onclick={triggerUpload}>Upload Database</button>
+</div>
+<input
+ type="file"
+ accept=".csv"
+ placeholder="Player database"
+ hidden
+ bind:this={fileInput}
+ onchange={handleFileChange}
+/>
diff --git a/src/lib/components/CourtList.svelte b/src/lib/components/CourtList.svelte
new file mode 100644
index 0000000..b307980
--- /dev/null
+++ b/src/lib/components/CourtList.svelte
@@ -0,0 +1,14 @@
+<script>
+ import ToggleButton from "./ToggleButton.svelte";
+ import { getCourts, toggleCourt } from "../stores/courts.svelte.js";
+</script>
+
+<div id="Court-list" class="grid-container">
+ {#each getCourts() as court (court.id)}
+ <ToggleButton
+ label={court.name}
+ active={court.status}
+ onclick={() => toggleCourt(court.id)}
+ />
+ {/each}
+</div>
diff --git a/src/lib/components/GameCard.svelte b/src/lib/components/GameCard.svelte
new file mode 100644
index 0000000..7bdacb6
--- /dev/null
+++ b/src/lib/components/GameCard.svelte
@@ -0,0 +1,17 @@
+<script>
+ import TeamDisplay from "./TeamDisplay.svelte";
+
+ let { game } = $props();
+ const reserved = $derived(game.teamOne.playerOne.firstName === "RESERVED");
+</script>
+
+<article>
+ <header>Court {game.id + 1}</header>
+ {#if reserved}
+ <h5>Reserved</h5>
+ {:else}
+ <TeamDisplay team={game.teamOne} />
+ <hr />
+ <TeamDisplay team={game.teamTwo} />
+ {/if}
+</article>
diff --git a/src/lib/components/GameCard.test.js b/src/lib/components/GameCard.test.js
new file mode 100644
index 0000000..076a409
--- /dev/null
+++ b/src/lib/components/GameCard.test.js
@@ -0,0 +1,32 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/svelte";
+import GameCard from "./GameCard.svelte";
+import { createCourt, createRegular } from "../logic/players.js";
+import {
+ addBlockedGames,
+ addActiveGames,
+} from "../logic/matchmaking.js";
+
+describe("GameCard", () => {
+ it("renders 'Reserved' for a RESERVED placeholder game", () => {
+ const [game] = addBlockedGames([createCourt(2, false)]);
+ render(GameCard, { game });
+ expect(screen.getByText("Reserved")).toBeInTheDocument();
+ expect(screen.getByText("Court 3")).toBeInTheDocument();
+ });
+
+ it("renders both teams' player names for a real match", () => {
+ const players = [
+ createRegular(0, true, 1, 0, "Alice", "A"),
+ createRegular(1, true, 1, 0, "Bob", "B"),
+ createRegular(2, true, 1, 0, "Carl", "C"),
+ createRegular(3, true, 1, 0, "Dana", "D"),
+ ];
+ const [game] = addActiveGames([createCourt(0, true)], players);
+ render(GameCard, { game });
+ expect(screen.queryByText("Reserved")).not.toBeInTheDocument();
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ expect(screen.getByText("Dana")).toBeInTheDocument();
+ });
+});
diff --git a/src/lib/components/GamesSection.svelte b/src/lib/components/GamesSection.svelte
new file mode 100644
index 0000000..a2f4f55
--- /dev/null
+++ b/src/lib/components/GamesSection.svelte
@@ -0,0 +1,11 @@
+<script>
+ import GameCard from "./GameCard.svelte";
+ import { getGames, getGameDescription } from "../stores/games.svelte.js";
+</script>
+
+<h1 class="title" id="Game-description">{getGameDescription()}</h1>
+<div id="Game-list" class="grid-container">
+ {#each getGames() as game (game.id)}
+ <GameCard {game} />
+ {/each}
+</div>
diff --git a/src/lib/components/GuestList.svelte b/src/lib/components/GuestList.svelte
new file mode 100644
index 0000000..2b597eb
--- /dev/null
+++ b/src/lib/components/GuestList.svelte
@@ -0,0 +1,14 @@
+<script>
+ import ToggleButton from "./ToggleButton.svelte";
+ import { getGuests, toggleGuest } from "../stores/guests.svelte.js";
+</script>
+
+<div id="Guest-list" class="grid-container">
+ {#each getGuests() as guest (guest.id)}
+ <ToggleButton
+ label={guest.name}
+ active={guest.status}
+ onclick={() => toggleGuest(guest.id)}
+ />
+ {/each}
+</div>
diff --git a/src/lib/components/ProposeConfirmControls.svelte b/src/lib/components/ProposeConfirmControls.svelte
new file mode 100644
index 0000000..692ad56
--- /dev/null
+++ b/src/lib/components/ProposeConfirmControls.svelte
@@ -0,0 +1,8 @@
+<script>
+ import { propose, confirm } from "../stores/games.svelte.js";
+</script>
+
+<div class="grid">
+ <button onclick={propose}>Propose</button>
+ <button onclick={confirm}>Confirm</button>
+</div>
diff --git a/src/lib/components/RegularList.svelte b/src/lib/components/RegularList.svelte
new file mode 100644
index 0000000..dd8fb7a
--- /dev/null
+++ b/src/lib/components/RegularList.svelte
@@ -0,0 +1,14 @@
+<script>
+ import ToggleButton from "./ToggleButton.svelte";
+ import { getRegulars, toggleRegular } from "../stores/regulars.svelte.js";
+</script>
+
+<div id="Regular-list" class="grid-container">
+ {#each getRegulars() as regular (regular.id)}
+ <ToggleButton
+ label={regular.name}
+ active={regular.status}
+ onclick={() => toggleRegular(regular.id)}
+ />
+ {/each}
+</div>
diff --git a/src/lib/components/SkillFactorSelect.svelte b/src/lib/components/SkillFactorSelect.svelte
new file mode 100644
index 0000000..bc882a7
--- /dev/null
+++ b/src/lib/components/SkillFactorSelect.svelte
@@ -0,0 +1,13 @@
+<script>
+ import { getSkillFactor, setSkillFactor } from "../stores/skillFactor.svelte.js";
+</script>
+
+<select
+ aria-label="State"
+ bind:value={() => String(getSkillFactor()), setSkillFactor}
+>
+ <option value="10">All Levels</option>
+ <option value="5">2 Levels</option>
+ <option value="3">3 Levels</option>
+ <option value="1">Skill Based</option>
+</select>
diff --git a/src/lib/components/TeamDisplay.svelte b/src/lib/components/TeamDisplay.svelte
new file mode 100644
index 0000000..e175446
--- /dev/null
+++ b/src/lib/components/TeamDisplay.svelte
@@ -0,0 +1,7 @@
+<script>
+ let { team } = $props();
+</script>
+
+<strong>{team.playerOne.firstName}</strong> {team.playerOne.lastName}
+<br /><br />
+<strong>{team.playerTwo.firstName}</strong> {team.playerTwo.lastName}
diff --git a/src/lib/components/TimerControls.svelte b/src/lib/components/TimerControls.svelte
new file mode 100644
index 0000000..5983514
--- /dev/null
+++ b/src/lib/components/TimerControls.svelte
@@ -0,0 +1,39 @@
+<script>
+ import {
+ getDisplay,
+ getRunning,
+ start,
+ stopTimer,
+ changeMinutes,
+ } from "../stores/timer.svelte.js";
+</script>
+
+<fieldset id="timer">
+ <button
+ title="Timer decrement"
+ disabled={getRunning()}
+ onclick={() => changeMinutes(-0.5)}
+ >
+ <i class="fa-solid fa-minus"></i>
+ </button>
+ <input
+ id="timer-display"
+ title="Time display"
+ type="text"
+ readonly
+ value={getDisplay()}
+ />
+ <button
+ title="Timer increment"
+ disabled={getRunning()}
+ onclick={() => changeMinutes(0.5)}
+ >
+ <i class="fa-solid fa-plus"></i>
+ </button>
+ <button title="Timer start" onclick={() => start(true)}>
+ <i class="fa-solid fa-play"></i>
+ </button>
+ <button title="Timer stop" onclick={() => stopTimer()}>
+ <i class="fa-solid fa-stop"></i>
+ </button>
+</fieldset>
diff --git a/src/lib/components/TimerControls.test.js b/src/lib/components/TimerControls.test.js
new file mode 100644
index 0000000..3176c17
--- /dev/null
+++ b/src/lib/components/TimerControls.test.js
@@ -0,0 +1,35 @@
+// @vitest-environment jsdom
+import { describe, it, expect, afterEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/svelte";
+import TimerControls from "./TimerControls.svelte";
+import { stopTimer } from "../stores/timer.svelte.js";
+
+describe("TimerControls", () => {
+ afterEach(() => {
+ stopTimer();
+ });
+
+ it("shows the resting display for the default minutes", () => {
+ render(TimerControls);
+ expect(screen.getByTitle("Time display")).toHaveValue("12:00");
+ });
+
+ it("disables inc/dec while running, and re-enables on stop", async () => {
+ render(TimerControls);
+ const startBtn = screen.getByTitle("Timer start");
+ const stopBtn = screen.getByTitle("Timer stop");
+ const incBtn = screen.getByTitle("Timer increment");
+ const decBtn = screen.getByTitle("Timer decrement");
+
+ expect(incBtn).not.toBeDisabled();
+ expect(decBtn).not.toBeDisabled();
+
+ await fireEvent.click(startBtn);
+ expect(incBtn).toBeDisabled();
+ expect(decBtn).toBeDisabled();
+
+ await fireEvent.click(stopBtn);
+ expect(incBtn).not.toBeDisabled();
+ expect(decBtn).not.toBeDisabled();
+ });
+});
diff --git a/src/lib/components/ToggleButton.svelte b/src/lib/components/ToggleButton.svelte
new file mode 100644
index 0000000..138aa15
--- /dev/null
+++ b/src/lib/components/ToggleButton.svelte
@@ -0,0 +1,7 @@
+<script>
+ let { label, active, onclick } = $props();
+</script>
+
+<button class:outline={!active} class:secondary={!active} {onclick}>
+ {label}
+</button>
diff --git a/src/lib/components/ToggleButton.test.js b/src/lib/components/ToggleButton.test.js
new file mode 100644
index 0000000..cfbb90d
--- /dev/null
+++ b/src/lib/components/ToggleButton.test.js
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/svelte";
+import ToggleButton from "./ToggleButton.svelte";
+
+describe("ToggleButton", () => {
+ it("renders the label", () => {
+ render(ToggleButton, { label: "Court 1", active: true, onclick: () => {} });
+ expect(screen.getByText("Court 1")).toBeInTheDocument();
+ });
+
+ it("shows the outline/secondary styling when inactive", () => {
+ render(ToggleButton, { label: "Court 1", active: false, onclick: () => {} });
+ const button = screen.getByRole("button");
+ expect(button).toHaveClass("outline", "secondary");
+ });
+
+ it("has no outline/secondary styling when active", () => {
+ render(ToggleButton, { label: "Court 1", active: true, onclick: () => {} });
+ const button = screen.getByRole("button");
+ expect(button).not.toHaveClass("outline");
+ expect(button).not.toHaveClass("secondary");
+ });
+
+ it("calls onclick when clicked", async () => {
+ const onclick = vi.fn();
+ render(ToggleButton, { label: "Court 1", active: true, onclick });
+ await fireEvent.click(screen.getByRole("button"));
+ expect(onclick).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/lib/logic/csv.js b/src/lib/logic/csv.js
new file mode 100644
index 0000000..9e085eb
--- /dev/null
+++ b/src/lib/logic/csv.js
@@ -0,0 +1,23 @@
+import { createRegular } from "./players.js";
+import { randomStatus } from "./random.js";
+
+export function parseLineToPlayer(line) {
+ const info = line.split(",");
+ const id = Number(info[0].trim());
+ const firstName = info[1].trim();
+ const lastName = info[2].trim();
+ const level = Number(info[3].trim());
+ return createRegular(id, randomStatus(), level, 0, firstName, lastName);
+}
+
+export function parseCsv(data) {
+ const lines = data.split("\n");
+ const players = [];
+ for (let i = 1; i < lines.length; i++) {
+ if (lines[i] === undefined || lines[i].trim() === "") {
+ continue;
+ }
+ players.push(parseLineToPlayer(lines[i]));
+ }
+ return players;
+}
diff --git a/src/lib/logic/csv.test.js b/src/lib/logic/csv.test.js
new file mode 100644
index 0000000..0381c06
--- /dev/null
+++ b/src/lib/logic/csv.test.js
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+import { parseLineToPlayer, parseCsv } from "./csv.js";
+
+describe("parseLineToPlayer", () => {
+ it("parses id, name, and level columns", () => {
+ const player = parseLineToPlayer("3, Jane, Doe, 7");
+ expect(player.id).toBe(3);
+ expect(player.firstName).toBe("Jane");
+ expect(player.lastName).toBe("Doe");
+ expect(player.level).toBe(7);
+ expect(player.games).toBe(0);
+ });
+});
+
+describe("parseCsv", () => {
+ it("skips the header row and blank lines", () => {
+ const csv = "id,firstName,lastName,level\n0, Jane, Doe, 7\n\n1, John, Smith, 3\n";
+ const players = parseCsv(csv);
+ expect(players).toHaveLength(2);
+ expect(players[0].firstName).toBe("Jane");
+ expect(players[1].firstName).toBe("John");
+ });
+
+ it("returns an empty array when there's only a header", () => {
+ expect(parseCsv("id,firstName,lastName,level")).toEqual([]);
+ });
+});
diff --git a/src/lib/logic/matchmaking.js b/src/lib/logic/matchmaking.js
new file mode 100644
index 0000000..eb8be2e
--- /dev/null
+++ b/src/lib/logic/matchmaking.js
@@ -0,0 +1,113 @@
+import { createRegular, createTeam, createGame } from "./players.js";
+import { normalize } from "./normalize.js";
+
+export function shuffle(array) {
+ const copy = [...array];
+ for (let i = copy.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [copy[i], copy[j]] = [copy[j], copy[i]];
+ }
+ return copy;
+}
+
+export function createPlaceholderGame(id, description = "---", status = true) {
+ const player = createRegular(null, null, null, null, description, "");
+ const team = createTeam(player, player);
+ return createGame(id, status, team, team);
+}
+
+export function isPlaceholderGame(game) {
+ return ["RESERVED", "---"].includes(game.teamOne.playerOne.firstName);
+}
+
+export function createDefaultGames(count) {
+ const games = [];
+ for (let i = 0; i < count; i++) {
+ games.push(createPlaceholderGame(i, "---", true));
+ }
+ return games;
+}
+
+export function addBlockedGames(inactiveCourts) {
+ return inactiveCourts.map((court) =>
+ createPlaceholderGame(court.id, "RESERVED", false)
+ );
+}
+
+export function addInactiveGames(unusedCourts) {
+ return unusedCourts.map((court) =>
+ createPlaceholderGame(court.id, "---", false)
+ );
+}
+
+export function getPossibleGameCount(activePlayerCount, activeCourtCount) {
+ return Math.min(Math.floor(activePlayerCount / 4), activeCourtCount);
+}
+
+export function selectPlayers(regulars, guests, count) {
+ const combined = shuffle([...regulars, ...guests]);
+ combined.sort((a, b) => a.games - b.games);
+ const selected = combined.slice(0, count);
+ selected.sort((a, b) => a.level - b.level);
+ return selected;
+}
+
+export function pairPlayersIntoTeams(players) {
+ const teamOne = createTeam(players[0], players[3]);
+ const teamTwo = createTeam(players[1], players[2]);
+ return [teamOne, teamTwo];
+}
+
+export function addActiveGames(usedCourts, selectedPlayers) {
+ const games = [];
+ for (let index = 0; index < usedCourts.length; index++) {
+ const startIndex = 4 * index;
+ const courtPlayers = selectedPlayers.slice(startIndex, startIndex + 4);
+ const [teamOne, teamTwo] = pairPlayersIntoTeams(courtPlayers);
+ games.push(createGame(usedCourts[index].id, false, teamOne, teamTwo));
+ }
+ return games;
+}
+
+export function proposeGames({ courts, regulars, guests, skillFactor }) {
+ const activeCourts = courts.filter((c) => c.status);
+ const inactiveCourts = courts.filter((c) => !c.status);
+ const activeRegulars = regulars.filter((r) => r.status);
+ const activeGuests = normalize(
+ guests.filter((g) => g.status),
+ skillFactor
+ );
+
+ const blockedGames = addBlockedGames(inactiveCourts);
+ const possibleMatches = getPossibleGameCount(
+ activeRegulars.length + activeGuests.length,
+ activeCourts.length
+ );
+ const usedCourts = activeCourts.slice(0, possibleMatches);
+ const unusedCourts = activeCourts.slice(possibleMatches);
+ const inactiveGames = addInactiveGames(unusedCourts);
+ const selectedPlayers = selectPlayers(
+ activeRegulars,
+ activeGuests,
+ possibleMatches * 4
+ );
+ const activeGames = addActiveGames(usedCourts, selectedPlayers);
+
+ return [...blockedGames, ...inactiveGames, ...activeGames].sort(
+ (a, b) => a.id - b.id
+ );
+}
+
+export function collectConfirmedPlayerNames(games) {
+ const names = [];
+ for (const game of games) {
+ if (isPlaceholderGame(game)) continue;
+ names.push(
+ game.teamOne.playerOne.name,
+ game.teamOne.playerTwo.name,
+ game.teamTwo.playerOne.name,
+ game.teamTwo.playerTwo.name
+ );
+ }
+ return names;
+}
diff --git a/src/lib/logic/matchmaking.test.js b/src/lib/logic/matchmaking.test.js
new file mode 100644
index 0000000..0401c1c
--- /dev/null
+++ b/src/lib/logic/matchmaking.test.js
@@ -0,0 +1,188 @@
+import { describe, it, expect } from "vitest";
+import { createCourt, createRegular, createGuest } from "./players.js";
+import {
+ shuffle,
+ getPossibleGameCount,
+ selectPlayers,
+ pairPlayersIntoTeams,
+ addBlockedGames,
+ addInactiveGames,
+ addActiveGames,
+ proposeGames,
+ collectConfirmedPlayerNames,
+ isPlaceholderGame,
+} from "./matchmaking.js";
+
+describe("shuffle", () => {
+ it("returns an array with the same elements, without mutating the input", () => {
+ const original = [1, 2, 3, 4, 5];
+ const copy = [...original];
+ const result = shuffle(original);
+ expect(original).toEqual(copy);
+ expect(result.slice().sort()).toEqual(original.slice().sort());
+ });
+});
+
+describe("getPossibleGameCount", () => {
+ it("is limited by available courts", () => {
+ expect(getPossibleGameCount(40, 2)).toBe(2);
+ });
+ it("is limited by available players (4 per game)", () => {
+ expect(getPossibleGameCount(5, 10)).toBe(1);
+ });
+ it("is zero when there are no active courts", () => {
+ expect(getPossibleGameCount(40, 0)).toBe(0);
+ });
+ it("is zero when there are fewer than 4 active players", () => {
+ expect(getPossibleGameCount(3, 10)).toBe(0);
+ });
+});
+
+describe("selectPlayers", () => {
+ it("prefers players with fewer games played", () => {
+ const regulars = [
+ createRegular(0, true, 5, 10, "Most", "Games"),
+ createRegular(1, true, 5, 0, "Fewest", "Games"),
+ ];
+ const selected = selectPlayers(regulars, [], 1);
+ expect(selected).toHaveLength(1);
+ expect(selected[0].name).toBe("Fewest Games");
+ });
+
+ it("sorts the final selection by level ascending", () => {
+ const regulars = [
+ createRegular(0, true, 8, 0, "High", "Level"),
+ createRegular(1, true, 2, 0, "Low", "Level"),
+ createRegular(2, true, 5, 0, "Mid", "Level"),
+ ];
+ const selected = selectPlayers(regulars, [], 3);
+ expect(selected.map((p) => p.level)).toEqual([2, 5, 8]);
+ });
+});
+
+describe("pairPlayersIntoTeams", () => {
+ it("cross-pairs players by rank (0&3 vs 1&2)", () => {
+ const players = [{ name: "A" }, { name: "B" }, { name: "C" }, { name: "D" }];
+ const [teamOne, teamTwo] = pairPlayersIntoTeams(players);
+ expect(teamOne.playerOne.name).toBe("A");
+ expect(teamOne.playerTwo.name).toBe("D");
+ expect(teamTwo.playerOne.name).toBe("B");
+ expect(teamTwo.playerTwo.name).toBe("C");
+ });
+});
+
+describe("addBlockedGames / addInactiveGames", () => {
+ it("marks games for inactive courts as RESERVED and inactive", () => {
+ const courts = [createCourt(3, false)];
+ const [game] = addBlockedGames(courts);
+ expect(game.id).toBe(3);
+ expect(game.status).toBe(false);
+ expect(isPlaceholderGame(game)).toBe(true);
+ expect(game.teamOne.playerOne.firstName).toBe("RESERVED");
+ });
+
+ it("marks games for unused active courts as placeholders", () => {
+ const courts = [createCourt(1, true)];
+ const [game] = addInactiveGames(courts);
+ expect(game.teamOne.playerOne.firstName).toBe("---");
+ });
+});
+
+describe("addActiveGames", () => {
+ it("creates one game per used court from the selected players", () => {
+ const usedCourts = [createCourt(0, true), createCourt(1, true)];
+ const players = Array.from({ length: 8 }, (_, i) =>
+ createRegular(i, true, 1, 0, `P${i}`, "")
+ );
+ const games = addActiveGames(usedCourts, players);
+ expect(games).toHaveLength(2);
+ expect(games[0].id).toBe(0);
+ expect(games[1].id).toBe(1);
+ expect(games[0].teamOne.playerOne.name).toBe("P0 ");
+ });
+});
+
+describe("proposeGames", () => {
+ it("never assigns more games than active courts", () => {
+ const courts = [createCourt(0, true), createCourt(1, true)];
+ const regulars = Array.from({ length: 20 }, (_, i) =>
+ createRegular(i, true, 1, 0, `R${i}`, "")
+ );
+ const games = proposeGames({
+ courts,
+ regulars,
+ guests: [],
+ skillFactor: 10,
+ });
+ expect(games).toHaveLength(2);
+ });
+
+ it("does not skip the court right after the used ones (no off-by-one gap)", () => {
+ const courts = [
+ createCourt(0, true),
+ createCourt(1, true),
+ createCourt(2, true),
+ ];
+ // Only enough players for exactly 1 active game, so courts 1 and 2
+ // should both come back as unused-but-active placeholder games.
+ const regulars = Array.from({ length: 4 }, (_, i) =>
+ createRegular(i, true, 1, 0, `R${i}`, "")
+ );
+ const games = proposeGames({
+ courts,
+ regulars,
+ guests: [],
+ skillFactor: 10,
+ });
+ expect(games.map((g) => g.id)).toEqual([0, 1, 2]);
+ expect(games[1].teamOne.playerOne.firstName).toBe("---");
+ expect(games[2].teamOne.playerOne.firstName).toBe("---");
+ });
+
+ it("gives reserved (inactive) courts a RESERVED placeholder", () => {
+ const courts = [createCourt(0, false)];
+ const games = proposeGames({
+ courts,
+ regulars: [],
+ guests: [],
+ skillFactor: 10,
+ });
+ expect(games).toHaveLength(1);
+ expect(games[0].teamOne.playerOne.firstName).toBe("RESERVED");
+ });
+
+ it("applies the skill factor to guests before pairing", () => {
+ const courts = [createCourt(0, true)];
+ const guests = [
+ createGuest(0, true, 0), // Beginner, level 1
+ createGuest(6, true, 0), // Novice, level 3
+ createGuest(12, true, 0), // Intermediate, level 6
+ createGuest(1, true, 0), // Beginner, level 1
+ ];
+ const games = proposeGames({
+ courts,
+ regulars: [],
+ guests,
+ skillFactor: 1,
+ });
+ expect(games).toHaveLength(1);
+ expect(games[0].status).toBe(false);
+ });
+});
+
+describe("collectConfirmedPlayerNames", () => {
+ it("excludes placeholder (RESERVED/---) games from the name list", () => {
+ const usedCourts = [createCourt(0, true)];
+ const players = [
+ createRegular(0, true, 1, 0, "A", ""),
+ createRegular(1, true, 1, 0, "B", ""),
+ createRegular(2, true, 1, 0, "C", ""),
+ createRegular(3, true, 1, 0, "D", ""),
+ ];
+ const realGame = addActiveGames(usedCourts, players)[0];
+ const [reservedGame] = addBlockedGames([createCourt(1, false)]);
+
+ const names = collectConfirmedPlayerNames([realGame, reservedGame]);
+ expect(names).toEqual(["A ", "D ", "B ", "C "]);
+ });
+});
diff --git a/src/lib/logic/normalize.js b/src/lib/logic/normalize.js
new file mode 100644
index 0000000..cfed0c3
--- /dev/null
+++ b/src/lib/logic/normalize.js
@@ -0,0 +1,6 @@
+export function normalize(data, factor) {
+ return data.map((player) => ({
+ ...player,
+ level: Math.ceil(player.level / factor),
+ }));
+}
diff --git a/src/lib/logic/normalize.test.js b/src/lib/logic/normalize.test.js
new file mode 100644
index 0000000..418e427
--- /dev/null
+++ b/src/lib/logic/normalize.test.js
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import { normalize } from "./normalize.js";
+
+describe("normalize", () => {
+ it("collapses all levels into one bucket when factor equals the max level (\"All Levels\")", () => {
+ const players = [{ level: 1 }, { level: 5 }, { level: 10 }];
+ const result = normalize(players, 10);
+ expect(result.map((p) => p.level)).toEqual([1, 1, 1]);
+ });
+
+ it("leaves levels untouched when factor is 1 (\"Skill Based\")", () => {
+ const players = [{ level: 1 }, { level: 5 }, { level: 10 }];
+ const result = normalize(players, 1);
+ expect(result.map((p) => p.level)).toEqual([1, 5, 10]);
+ });
+
+ it("splits levels into two buckets when factor is 5 (\"2 Levels\")", () => {
+ const players = [{ level: 1 }, { level: 5 }, { level: 6 }, { level: 10 }];
+ const result = normalize(players, 5);
+ expect(result.map((p) => p.level)).toEqual([1, 1, 2, 2]);
+ });
+
+ it("does not mutate the input array", () => {
+ const players = [{ level: 4 }];
+ normalize(players, 2);
+ expect(players[0].level).toBe(4);
+ });
+});
diff --git a/src/lib/logic/players.js b/src/lib/logic/players.js
new file mode 100644
index 0000000..e11c32f
--- /dev/null
+++ b/src/lib/logic/players.js
@@ -0,0 +1,73 @@
+export function createCourt(id, status = false) {
+ return {
+ id,
+ status,
+ description: "Court",
+ name: `Court ${id + 1}`,
+ };
+}
+
+export const guestCategories = {
+ 0: { description: "Beginner", level: 1 },
+ 1: { description: "Novice", level: 3 },
+ 2: { description: "Intermediate", level: 6 },
+};
+
+export const GUESTS_PER_CATEGORY = 6;
+
+export function createRegular(
+ id,
+ status = false,
+ level = 1,
+ games = 0,
+ firstName = "Regular",
+ lastName = id + 1
+) {
+ return {
+ id,
+ status,
+ description: "Regular",
+ level,
+ games,
+ firstName,
+ lastName,
+ name: `${firstName} ${lastName}`,
+ };
+}
+
+export function createGuest(id, status = false, games = 0) {
+ const categoryId = Math.floor(id / GUESTS_PER_CATEGORY) || 0;
+ const category = guestCategories[categoryId];
+ const guestNumber = (id % GUESTS_PER_CATEGORY) + 1;
+ const firstName = category.description;
+ const lastName = guestNumber;
+ return {
+ id,
+ status,
+ description: "Guest",
+ level: category.level,
+ games,
+ firstName,
+ lastName,
+ name: `${firstName} ${lastName}`,
+ };
+}
+
+export function createTeam(playerOne, playerTwo) {
+ return { playerOne, playerTwo };
+}
+
+export function createGame(id, status, teamOne, teamTwo) {
+ return {
+ id,
+ status,
+ description: "Game",
+ teamOne,
+ teamTwo,
+ name: `Game ${id + 1}`,
+ };
+}
+
+export function isReserved(game) {
+ return game.teamOne.playerOne.firstName === "RESERVED";
+}
diff --git a/src/lib/logic/random.js b/src/lib/logic/random.js
new file mode 100644
index 0000000..a696860
--- /dev/null
+++ b/src/lib/logic/random.js
@@ -0,0 +1,8 @@
+export function randomStatus() {
+ return Math.random() < 0.5;
+}
+
+export function randomLevel() {
+ const maxLevel = 10;
+ return Math.floor(Math.random() * maxLevel + 1);
+}
diff --git a/src/lib/stores/courts.svelte.js b/src/lib/stores/courts.svelte.js
new file mode 100644
index 0000000..768bb35
--- /dev/null
+++ b/src/lib/stores/courts.svelte.js
@@ -0,0 +1,39 @@
+import { createCourt } from "../logic/players.js";
+import { randomStatus } from "../logic/random.js";
+
+const STORAGE_KEY = "Court";
+const COURT_COUNT = 12;
+
+function loadInitialCourts() {
+ const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ if (stored && stored.length > 0) {
+ return stored.map((c) => createCourt(Number(c.id), Boolean(Number(c.status))));
+ }
+ return Array.from({ length: COURT_COUNT }, (_, i) => createCourt(i, randomStatus()));
+}
+
+let courts = $state(loadInitialCourts());
+
+function persist() {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(courts));
+}
+if (!localStorage.getItem(STORAGE_KEY)) persist();
+
+export function getCourts() {
+ return courts;
+}
+
+export function activeCourts() {
+ return courts.filter((c) => c.status);
+}
+
+export function inactiveCourts() {
+ return courts.filter((c) => !c.status);
+}
+
+export function toggleCourt(id) {
+ const court = courts.find((c) => c.id === id);
+ if (!court) return;
+ court.status = !court.status;
+ persist();
+}
diff --git a/src/lib/stores/games.svelte.js b/src/lib/stores/games.svelte.js
new file mode 100644
index 0000000..ae76ce3
--- /dev/null
+++ b/src/lib/stores/games.svelte.js
@@ -0,0 +1,91 @@
+import { createGame, createTeam } from "../logic/players.js";
+import {
+ proposeGames,
+ createDefaultGames,
+ collectConfirmedPlayerNames,
+} from "../logic/matchmaking.js";
+import {
+ getCourts,
+} from "./courts.svelte.js";
+import {
+ getRegulars,
+ incrementRegularGames,
+ resetRegulars,
+} from "./regulars.svelte.js";
+import { getGuests, incrementGuestGames } from "./guests.svelte.js";
+import { getSkillFactor } from "./skillFactor.svelte.js";
+import { start as startTimer, stopTimer } from "./timer.svelte.js";
+
+const GAMES_STORAGE_KEY = "Game";
+const ROUND_STORAGE_KEY = "round";
+const GAME_COUNT = 12;
+
+function loadInitialGames() {
+ const stored = JSON.parse(localStorage.getItem(GAMES_STORAGE_KEY));
+ if (stored && stored.length > 0) {
+ return stored.map((g) =>
+ createGame(
+ Number(g.id),
+ Boolean(g.status),
+ createTeam(g.teamOne.playerOne, g.teamOne.playerTwo),
+ createTeam(g.teamTwo.playerOne, g.teamTwo.playerTwo)
+ )
+ );
+ }
+ return createDefaultGames(GAME_COUNT);
+}
+
+let games = $state(loadInitialGames());
+let round = $state(Number(localStorage.getItem(ROUND_STORAGE_KEY)) || 0);
+
+function persistGames() {
+ localStorage.setItem(GAMES_STORAGE_KEY, JSON.stringify(games));
+}
+
+export function getGames() {
+ return games;
+}
+
+export function getRound() {
+ return round;
+}
+
+export function getGameDescription() {
+ const confirmed = games[0]?.status;
+ return confirmed ? `Round ${round}` : "Proposal";
+}
+
+export function propose() {
+ games = proposeGames({
+ courts: getCourts(),
+ regulars: getRegulars(),
+ guests: getGuests(),
+ skillFactor: getSkillFactor(),
+ });
+}
+
+export function confirm() {
+ const alreadyConfirmed = games[0]?.status;
+ if (alreadyConfirmed) return;
+
+ round += 1;
+ localStorage.setItem(ROUND_STORAGE_KEY, round);
+ for (const game of games) game.status = true;
+
+ const selectedNames = collectConfirmedPlayerNames(games);
+ incrementRegularGames(selectedNames);
+ incrementGuestGames(selectedNames);
+
+ persistGames();
+ startTimer(true);
+}
+
+export function resetSession() {
+ localStorage.removeItem(ROUND_STORAGE_KEY);
+ localStorage.removeItem(GAMES_STORAGE_KEY);
+ localStorage.removeItem("Court");
+ localStorage.removeItem("Guest");
+ resetRegulars();
+ stopTimer();
+ window.location.reload();
+}
diff --git a/src/lib/stores/guests.svelte.js b/src/lib/stores/guests.svelte.js
new file mode 100644
index 0000000..6dc08db
--- /dev/null
+++ b/src/lib/stores/guests.svelte.js
@@ -0,0 +1,46 @@
+import { createGuest } from "../logic/players.js";
+import { randomStatus } from "../logic/random.js";
+
+const STORAGE_KEY = "Guest";
+const GUEST_COUNT = 18;
+
+function loadInitialGuests() {
+ const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ if (stored && stored.length > 0) {
+ return stored.map((g) =>
+ createGuest(Number(g.id), Boolean(Number(g.status)), Number(g.games))
+ );
+ }
+ return Array.from({ length: GUEST_COUNT }, (_, i) => createGuest(i, randomStatus()));
+}
+
+let guests = $state(loadInitialGuests());
+
+function persist() {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(guests));
+}
+if (!localStorage.getItem(STORAGE_KEY)) persist();
+
+export function getGuests() {
+ return guests;
+}
+
+export function activeGuests() {
+ return guests.filter((g) => g.status);
+}
+
+export function toggleGuest(id) {
+ const guest = guests.find((g) => g.id === id);
+ if (!guest) return;
+ guest.status = !guest.status;
+ persist();
+}
+
+export function incrementGuestGames(names) {
+ for (const guest of guests) {
+ if (names.includes(guest.name)) {
+ guest.games += 1;
+ }
+ }
+ persist();
+}
diff --git a/src/lib/stores/regulars.svelte.js b/src/lib/stores/regulars.svelte.js
new file mode 100644
index 0000000..ccf0d81
--- /dev/null
+++ b/src/lib/stores/regulars.svelte.js
@@ -0,0 +1,79 @@
+import { uniqueNamesGenerator, names, adjectives } from "unique-names-generator";
+import { createRegular } from "../logic/players.js";
+import { randomStatus, randomLevel } from "../logic/random.js";
+import { parseCsv } from "../logic/csv.js";
+
+const STORAGE_KEY = "Regular";
+const REGULAR_COUNT = 71;
+
+const nameConfig = {
+ dictionaries: [adjectives, names],
+ style: "capital",
+ length: 2,
+};
+
+function generateRandomRegular(id) {
+ const [firstName, lastName] = uniqueNamesGenerator(nameConfig).split("_");
+ return createRegular(id, randomStatus(), randomLevel(), 0, firstName, lastName);
+}
+
+function loadInitialRegulars() {
+ const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ if (stored && stored.length > 0) {
+ return stored.map((r) =>
+ createRegular(
+ Number(r.id),
+ Boolean(Number(r.status)),
+ Number(r.level),
+ Number(r.games),
+ r.firstName,
+ r.lastName
+ )
+ );
+ }
+ return Array.from({ length: REGULAR_COUNT }, (_, i) => generateRandomRegular(i));
+}
+
+let regulars = $state(loadInitialRegulars());
+
+function persist() {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(regulars));
+}
+if (!localStorage.getItem(STORAGE_KEY)) persist();
+
+export function getRegulars() {
+ return regulars;
+}
+
+export function activeRegulars() {
+ return regulars.filter((r) => r.status);
+}
+
+export function toggleRegular(id) {
+ const regular = regulars.find((r) => r.id === id);
+ if (!regular) return;
+ regular.status = !regular.status;
+ persist();
+}
+
+export function incrementRegularGames(playerNames) {
+ for (const regular of regulars) {
+ if (playerNames.includes(regular.name)) {
+ regular.games += 1;
+ }
+ }
+ persist();
+}
+
+export function resetRegulars() {
+ for (const regular of regulars) {
+ regular.games = 0;
+ regular.status = false;
+ }
+ persist();
+}
+
+export function loadRegularsFromCsv(csvText) {
+ regulars = parseCsv(csvText);
+ persist();
+}
diff --git a/src/lib/stores/skillFactor.svelte.js b/src/lib/stores/skillFactor.svelte.js
new file mode 100644
index 0000000..d031040
--- /dev/null
+++ b/src/lib/stores/skillFactor.svelte.js
@@ -0,0 +1,9 @@
+let skillFactor = $state(10); // "All Levels" — matches the #skill select's original default
+
+export function getSkillFactor() {
+ return skillFactor;
+}
+
+export function setSkillFactor(value) {
+ skillFactor = Number(value);
+}
diff --git a/src/lib/stores/timer.svelte.js b/src/lib/stores/timer.svelte.js
new file mode 100644
index 0000000..dea1799
--- /dev/null
+++ b/src/lib/stores/timer.svelte.js
@@ -0,0 +1,106 @@
+import { Notyf } from "notyf";
+
+const DEFAULT_MINS = 12;
+const MIN_MINS = 0.5;
+
+const initialMins = Number(sessionStorage.getItem("mins")) || DEFAULT_MINS;
+const initialRunning = Boolean(Number(sessionStorage.getItem("running")));
+const initialDeadline = Number(sessionStorage.getItem("deadline")) || Date.now();
+const initialRemainingMs = initialRunning
+ ? Math.max(initialDeadline - Date.now(), 0)
+ : initialMins * 60 * 1000;
+
+let mins = $state(initialMins);
+let running = $state(initialRunning);
+let deadline = $state(initialDeadline);
+let remainingMs = $state(initialRemainingMs);
+
+let intervalId = null;
+
+function persist() {
+ sessionStorage.setItem("mins", mins);
+ sessionStorage.setItem("running", Number(running));
+ sessionStorage.setItem("deadline", deadline);
+}
+
+function showRoundCompletedToast() {
+ const notyf = new Notyf({
+ duration: 0,
+ position: { x: "center", y: "top" },
+ dismissible: true,
+ });
+ notyf.error("Round completed");
+}
+
+function stopInterval() {
+ if (intervalId !== null) clearInterval(intervalId);
+ intervalId = null;
+}
+
+function tick() {
+ const msToDeadline = deadline - Date.now();
+ if (msToDeadline > 0) {
+ remainingMs = msToDeadline;
+ return;
+ }
+ stopInterval();
+ running = false;
+ remainingMs = mins * 60 * 1000;
+ persist();
+ showRoundCompletedToast();
+}
+
+export function getMins() {
+ return mins;
+}
+
+export function getRunning() {
+ return running;
+}
+
+export function getRemainingMs() {
+ return remainingMs;
+}
+
+export function formatRemaining(ms) {
+ const m = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
+ const s = Math.floor((ms % (1000 * 60)) / 1000);
+ const pad = (n) => (n < 10 ? "0" + n : String(n));
+ return `${pad(m)}:${pad(s)}`;
+}
+
+export function getDisplay() {
+ return formatRemaining(remainingMs);
+}
+
+export function stopTimer() {
+ stopInterval();
+ running = false;
+ remainingMs = mins * 60 * 1000;
+ persist();
+}
+
+export function start(fresh = true) {
+ if (fresh && running) return;
+ if (fresh) {
+ deadline = Date.now() + mins * 60 * 1000;
+ }
+ running = true;
+ remainingMs = Math.max(deadline - Date.now(), 0);
+ persist();
+ stopInterval();
+ intervalId = setInterval(tick, 1000);
+}
+
+export function changeMinutes(delta) {
+ mins = Math.max(mins + delta, MIN_MINS);
+ stopTimer();
+}
+
+// Resume a timer that was still running across a page reload, otherwise
+// settle the display to the resting (not-running) state.
+if (initialRunning) {
+ start(false);
+} else {
+ stopTimer();
+}
diff --git a/src/lib/test-setup.js b/src/lib/test-setup.js
new file mode 100644
index 0000000..45b115a
--- /dev/null
+++ b/src/lib/test-setup.js
@@ -0,0 +1,7 @@
+import "@testing-library/jest-dom/vitest";
+import { afterEach } from "vitest";
+import { cleanup } from "@testing-library/svelte";
+
+afterEach(() => {
+ cleanup();
+});