diff options
| author | Karan Jayachandra <mail@karanjayachandra.com> | 2026-08-30 22:43:21 +0200 |
|---|---|---|
| committer | Karan Jayachandra <mail@karanjayachandra.com> | 2026-08-30 22:43:21 +0200 |
| commit | fc8d8ab8d0da95ec79056f59e14a150df6fcea89 (patch) | |
| tree | 33f8050b2f2920c0f1b884a205a8aa4c81d7687c /src/lib | |
| parent | d37ebfda883bcd03b146a85bc3e5a4657d0abd73 (diff) | |
Moved to sveltemain
Diffstat (limited to 'src/lib')
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(); +}); |
