aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKaran Jayachandra <mail@karanjayachandra.com>2026-08-30 22:43:21 +0200
committerKaran Jayachandra <mail@karanjayachandra.com>2026-08-30 22:43:21 +0200
commitfc8d8ab8d0da95ec79056f59e14a150df6fcea89 (patch)
tree33f8050b2f2920c0f1b884a205a8aa4c81d7687c
parentd37ebfda883bcd03b146a85bc3e5a4657d0abd73 (diff)
Moved to sveltemain
-rw-r--r--CONTRIBUTING.md23
-rw-r--r--index.html74
-rw-r--r--package-lock.json1490
-rw-r--r--package.json12
-rw-r--r--src/App.svelte62
-rw-r--r--src/data.js111
-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
-rw-r--r--src/main.js181
-rw-r--r--src/manage.js214
-rw-r--r--src/timer.js91
-rw-r--r--svelte.config.js5
-rw-r--r--vite.config.js11
40 files changed, 2709 insertions, 683 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9c15148..a1f39c2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,10 +1,10 @@
# Contributing
-To contribute you need to have [git](https://git-scm.com/) and [UV](https://docs.astral.sh/uv/) setup.
+To contribute you need [git](https://git-scm.com/) and [Node.js](https://nodejs.org/) set up.
## Setup
-Set up the repository via a git clone and setup a new branch using the following commands:
+Set up the repository via a git clone and set up a new branch using the following commands:
```bash
git clone git@gitlab.com:KaranJayachandra/match_up.git
@@ -14,21 +14,26 @@ git branch <branch-name>
git checkout <branch-name>
```
-Now we can install the dependencies using:
+Now install the dependencies:
```bash
-uv sync
+npm install
```
-A database URI needs to be added to let the application know where to create a test database by creating a `.flaskenv` file in the root directory containing the variable `SQLALCHEMY_DATABASE_URI=sqlite:///app.db`. Now, we need to initialize a database for testing via the following commands:
+Start the dev server:
```bash
-uv run flask init
-uv run flask migrate
+npm run dev
```
-We are now ready to run the flask application using:
+Run the test suite:
```bash
-uv run flask run
+npm run test
+```
+
+Build a production bundle (output goes to `dist/`):
+
+```bash
+npm run build
```
diff --git a/index.html b/index.html
index 1df8fb3..3de840a 100644
--- a/index.html
+++ b/index.html
@@ -10,75 +10,7 @@
<meta http-equiv="Cache-control" content="public">
</head>
<body>
- <header class="container">
- <nav>
- <ul><li><img src="icon.svg" alt="Matchup"></li></ul>
- <ul>
- <li>
- <fieldset id="timer" role="group">
- <button id="timer-dec" title="Timer decrement"><i class="fa-solid fa-minus"></i></button>
- <input id="timer-display" title="Time display" type="text" readonly></input>
- <button id="timer-inc" title="Timer increment"><i class="fa-solid fa-plus"></i></button>
- <button id="timer-start" title="Timer start"><i class="fa-solid fa-play"></i></button>
- <button id="timer-stop" title="Timer stop"><i class="fa-solid fa-stop"></i></button>
- </fieldset>
- </li>
- </ul>
- </nav>
- </header>
- <main class="container">
- <section>
- <h1 class="title" id="Game-description"></h1>
- <div id="Game-list" class="grid-container"></div>
- </section>
- <section>
- <select id="skill" aria-label="State">
- <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>
- <div class="grid">
- <button id="propose">Propose</button>
- <button id="confirm">Confirm</button>
- </div>
- </section>
- <section>
- <hgroup>
- <h2>Courts</h2>
- <p>Reserve courts for training or competition.</p>
- </hgroup>
- <div id="Court-list" class="grid-container"></div>
- </section>
- <section>
- <hgroup>
- <h2>Regulars</h2>
- <p>Check in and check out club members.</p>
- </hgroup>
- <div id="Regular-list" class="grid-container"></div>
- </section>
- <section>
- <hgroup>
- <h2>Guests</h2>
- <p>Add guests to the pool.</p>
- </hgroup>
- <div id="Guest-list" class="grid-container"></div>
- </section>
- <section>
- <hgroup>
- <h2>Admin</h2>
- <p>Changes here are permanent and cannot be reverted.</p>
- </hgroup>
- <div id="admin" class="grid">
- <button id="reset">Reset Session</button>
- <button id='button-player-load'>Upload Database</button>
- </div>
- <input type="file" id="player-load" accept=".csv" placeholder="Player database" hidden/>
- </div>
- </main>
- <footer class="container" style="text-align: center;">
- <strong>Matchup</strong> made with ❤️ by <a href="https://karanj.com">Karan</a>. The source code is licensed <a href="https://www.gnu.org/licenses/agpl-3.0.en.html">AGPL 3.0</a>. Logo from <a href="https://www.svgrepo.com">SVG Repo</a>.
- </footer>
+ <div id="app"></div>
+ <script src="src/main.js" type="module"></script>
</body>
- <script src="src/main.js" type="module"></script>
-</html> \ No newline at end of file
+</html>
diff --git a/package-lock.json b/package-lock.json
index b5f333e..6671f17 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,12 +10,245 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"@picocss/pico": "^2.1.1",
- "bulma": "^1.0.4",
"notyf": "^3.10.0",
"unique-names-generator": "^4.7.1"
},
"devDependencies": {
- "vite": "npm:rolldown-vite@7.2.2"
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/svelte": "^5.4.2",
+ "jsdom": "^30.0.1",
+ "svelte": "^5.57.0",
+ "vite": "npm:rolldown-vite@7.2.2",
+ "vitest": "^4.1.11"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
+ "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.3.0",
+ "@csstools/css-color-parser": "^4.1.10",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
+ "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz",
+ "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz",
+ "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
@@ -52,6 +285,24 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.1.0.tgz",
@@ -61,6 +312,56 @@
"node": ">=6"
}
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
@@ -345,6 +646,149 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
+ "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz",
+ "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^4.3.1",
+ "magic-string": "^1.0.0",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.2"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.46.4",
+ "vite": "^8.0.0-beta.7 || ^8.0.0"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/svelte": {
+ "version": "5.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz",
+ "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@testing-library/dom": "9.x.x || 10.x.x",
+ "@testing-library/svelte-core": "1.1.3"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "peerDependencies": {
+ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
+ "vite": "*",
+ "vitest": "*"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ },
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/svelte-core": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz",
+ "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -356,12 +800,351 @@
"tslib": "^2.4.0"
}
},
- "node_modules/bulma": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz",
- "integrity": "sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==",
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
"license": "MIT"
},
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -372,6 +1155,85 @@
"node": ">=8"
}
},
+ "node_modules/devalue": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz",
+ "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz",
+ "integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -405,6 +1267,94 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+ "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^6.0.5",
+ "@asamuzakjp/dom-selector": "^8.3.0",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.9.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -666,6 +1616,60 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
+ "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -691,6 +1695,40 @@
"integrity": "sha512-Mtnp+0qiZxgrH+TzVlzhWyZceHdAZ/UWK0/ju9U0HQeDpap1mZ8cC7H5wSI5mwgni6yeAjaxsTw0sbMK+aSuHw==",
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -704,7 +1742,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -741,6 +1778,62 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/rolldown": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.47.tgz",
@@ -774,6 +1867,26 @@
"@rolldown/binding-win32-x64-msvc": "1.0.0-beta.47"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -784,6 +1897,94 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.57.0",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz",
+ "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.10",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.8.1",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.12",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -801,6 +2002,62 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.11",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz",
+ "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.11"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.11",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz",
+ "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -809,6 +2066,16 @@
"license": "0BSD",
"optional": true
},
+ "node_modules/undici": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
+ "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/unique-names-generator": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
@@ -894,6 +2161,215 @@
"optional": true
}
}
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
+ "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index 11d527b..8d52c3c 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,17 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run"
},
"devDependencies": {
- "vite": "npm:rolldown-vite@7.2.2"
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/svelte": "^5.4.2",
+ "jsdom": "^30.0.1",
+ "svelte": "^5.57.0",
+ "vite": "npm:rolldown-vite@7.2.2",
+ "vitest": "^4.1.11"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.2"
@@ -17,7 +24,6 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"@picocss/pico": "^2.1.1",
- "bulma": "^1.0.4",
"notyf": "^3.10.0",
"unique-names-generator": "^4.7.1"
}
diff --git a/src/App.svelte b/src/App.svelte
new file mode 100644
index 0000000..128685a
--- /dev/null
+++ b/src/App.svelte
@@ -0,0 +1,62 @@
+<script>
+ import TimerControls from "./lib/components/TimerControls.svelte";
+ import GamesSection from "./lib/components/GamesSection.svelte";
+ import SkillFactorSelect from "./lib/components/SkillFactorSelect.svelte";
+ import ProposeConfirmControls from "./lib/components/ProposeConfirmControls.svelte";
+ import CourtList from "./lib/components/CourtList.svelte";
+ import RegularList from "./lib/components/RegularList.svelte";
+ import GuestList from "./lib/components/GuestList.svelte";
+ import AdminPanel from "./lib/components/AdminPanel.svelte";
+</script>
+
+<header class="container">
+ <nav>
+ <ul><li><img src="icon.svg" alt="Matchup" /></li></ul>
+ <ul>
+ <li><TimerControls /></li>
+ </ul>
+ </nav>
+</header>
+<main class="container">
+ <section>
+ <GamesSection />
+ </section>
+ <section>
+ <SkillFactorSelect />
+ <ProposeConfirmControls />
+ </section>
+ <section>
+ <hgroup>
+ <h2>Courts</h2>
+ <p>Reserve courts for training or competition.</p>
+ </hgroup>
+ <CourtList />
+ </section>
+ <section>
+ <hgroup>
+ <h2>Regulars</h2>
+ <p>Check in and check out club members.</p>
+ </hgroup>
+ <RegularList />
+ </section>
+ <section>
+ <hgroup>
+ <h2>Guests</h2>
+ <p>Add guests to the pool.</p>
+ </hgroup>
+ <GuestList />
+ </section>
+ <section>
+ <hgroup>
+ <h2>Admin</h2>
+ <p>Changes here are permanent and cannot be reverted.</p>
+ </hgroup>
+ <AdminPanel />
+ </section>
+</main>
+<footer class="container" style="text-align: center;">
+ <strong>Matchup</strong> made with ❤️ by <a href="https://karanj.com">Karan</a>. The
+ source code is licensed
+ <a href="https://www.gnu.org/licenses/agpl-3.0.en.html">AGPL 3.0</a>. Logo
+ from <a href="https://www.svgrepo.com">SVG Repo</a>.
+</footer>
diff --git a/src/data.js b/src/data.js
deleted file mode 100644
index 0b0c9ab..0000000
--- a/src/data.js
+++ /dev/null
@@ -1,111 +0,0 @@
-class Switch {
- constructor(id, description, status) {
- this.id = id;
- this.status = typeof status !== "undefined" ? status : false;
- this.description = description;
- this.name = this.description + " " + (id + 1);
- }
- toggle() {
- this.status = !this.status;
- this.print();
- }
- print() {
- let status = this.status ? "active" : "inactive";
- console.log(this.name + " is currently " + status);
- }
- render() {
- let color = this.status ? `` : `class="outline secondary"`;
- let id = this.description + "_" + this.id;
- return `<button id=${id} ${color}>${this.name}</button>`;
- }
-}
-
-export class Court extends Switch {
- constructor(id, status) {
- super(id, "Court", status);
- }
-}
-
-export class Regular extends Switch {
- constructor(id, status, level, games, firstName, lastName) {
- super(id, "Regular", status);
- this.level = typeof level !== "undefined" ? level : 1;
- this.games = typeof games !== "undefined" ? games : 0;
- this.firstName =
- typeof firstName !== "undefined" ? firstName : this.description;
- this.lastName = typeof lastName !== "undefined" ? lastName : this.id + 1;
- this.name = this.firstName + " " + this.lastName;
- }
-}
-
-const guestCategories = {
- 0: { description: "Beginner", level: 1 },
- 1: { description: "Novice", level: 3 },
- 2: { description: "Intermediate", level: 6 },
-};
-
-export class Guest extends Switch {
- maxCount = 6;
- categoryCount = 3;
-
- constructor(id, status, games) {
- super(id, "Guest", status);
- const categoryId = Math.floor(id / this.maxCount) || 0;
- this.level = guestCategories[categoryId].level;
- this.games = typeof games !== "undefined" ? games : 0;
- const guestNumber = (id % this.maxCount) + 1;
- this.firstName = guestCategories[categoryId].description;
- this.lastName = guestNumber;
- this.name = this.firstName + " " + this.lastName;
- }
-}
-
-function renderPlayerName(player) {
- return `<strong>${player.firstName}</strong> ${player.lastName}`;
-}
-
-export class Team {
- constructor(playerOne, playerTwo) {
- this.playerOne = playerOne;
- this.playerTwo = playerTwo;
- }
- print() {
- console.log("Team consists of:");
- this.playerOne.print();
- this.playerTwo.print();
- }
- render() {
- const p1 = renderPlayerName(this.playerOne);
- const p2 = renderPlayerName(this.playerTwo);
- return p1 + `<br/><br/>` + p2;
- }
-}
-
-export class Game extends Switch {
- constructor(id, status, teamOne, teamTwo) {
- super(id, "Game", status);
- this.teamOne = teamOne;
- this.teamTwo = teamTwo;
- }
- print() {
- const teamOne =
- this.teamOne.playerOne.firstName +
- " and " +
- this.teamOne.playerTwo.firstName;
- const teamTwo =
- this.teamTwo.playerOne.firstName +
- " and " +
- this.teamTwo.playerTwo.firstName;
- console.log(
- "Game " + this.id + ": " + teamOne + " is playing against " + teamTwo
- );
- }
- render() {
- const blockedState = this.teamOne.playerOne.firstName === "RESERVED";
- const playerState = this.teamOne.render() + "<hr>" + this.teamTwo.render();
- const type = blockedState ? `<h5>Reserved</p>` : playerState;
- return `<article>
- <header>Court ${this.id + 1}</header>${type}
- </article>`;
- }
-}
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();
+});
diff --git a/src/main.js b/src/main.js
index 59f66d0..f6228c9 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,178 +1,5 @@
-import "./style.css"
-import { Timer } from "./timer";
-import { Team, Game, Regular } from "./data";
-import { Manager, CourtManager, RegularManager, GuestManager } from "./manage";
-
-
-export class GameManager extends Manager {
- timer = new Timer();
- courts = new CourtManager();
- regulars = new RegularManager();
- guests = new GuestManager();
-
- constructor() {
- super("Game", 12);
- this.round = Number(localStorage.getItem("round")) || 0;
- this.render();
- addGameBehaviour(this);
- }
- createItem(i, description, status) {
- if (typeof description === "undefined") {
- description = "---";
- }
- if (typeof status === "undefined") {
- status = true;
- }
- let player = new Regular(null, null, null, null, description, "");
- console.log(player);
- let team = new Team(player, player);
- return new Game(i, status, team, team);
- }
- loadItem(data) {
- let id = Number(data.id);
- let teamOne = new Team(data.teamOne.playerOne, data.teamOne.playerTwo);
- let teamTwo = new Team(data.teamTwo.playerOne, data.teamTwo.playerTwo);
- let status = Boolean(data.status);
- return new Game(id, status, teamOne, teamTwo);
- }
- addBehaviour() {
- let confirmedStatus = this.data[0].status;
- let description = confirmedStatus ? "Round " + this.round : "Proposal";
- document.getElementById("Game-description").innerHTML = description;
- }
- addBlockedGames() {
- const inactiveCourts = this.courts.inactive();
- console.log("Total reserved courts are " + inactiveCourts.length + ".");
- for (let court of inactiveCourts) {
- this.data.push(this.createItem(court.id, "RESERVED", false));
- }
- }
- addInactiveGames(possibleMatches) {
- const activeCourts = this.courts.active();
- const unusedCourts = activeCourts.slice(possibleMatches + 1);
- for (let court of unusedCourts) {
- this.data.push(this.createItem(court.id, "---", false));
- }
- }
- addActiveGames(possibleMatches) {
- const activeCourts = this.courts.active();
- const usedCourts = activeCourts.slice(0, possibleMatches);
- let requiredPlayerCount = possibleMatches * 4;
- const selectedPlayers = this.selectPlayers(requiredPlayerCount);
- for (let index = 0; index < usedCourts.length; index++) {
- const startIndex = 4 * index;
- const endIndex = startIndex + 4;
- const courtPlayers = selectedPlayers.slice(startIndex, endIndex);
- const teamOne = new Team(courtPlayers[0], courtPlayers[3]);
- const teamTwo = new Team(courtPlayers[1], courtPlayers[2]);
- const game = new Game(usedCourts[index].id, false, teamOne, teamTwo);
- this.data.push(game);
- }
- }
- getPossibleGameCount() {
- let activeRegularCount = this.regulars.active().length;
- let activeGuestCount = this.guests.active().length;
- let activePlayerCount = activeRegularCount + activeGuestCount;
- console.log("Total players available: " + activePlayerCount);
- let activeCourtCount = this.courts.active().length;
- console.log("There courts available: " + activeCourtCount);
- let possibleMatches = Math.min(
- Math.floor(activePlayerCount / 4),
- activeCourtCount
- );
- return possibleMatches;
- }
- selectPlayers(count) {
- console.log("Required players are " + count);
- let activePlayers = this.regulars.active();
- const activeGuests = this.guests.active();
- activePlayers = activePlayers.concat(activeGuests);
- console.log("Active players are:");
- activePlayers.map((p) => {
- p.print();
- });
- const shuffledPlayers = shuffle(activePlayers);
- shuffledPlayers.sort((a, b) => a.games - b.games);
- const selectedPlayers = shuffledPlayers.slice(0, count);
- selectedPlayers.sort((a, b) => a.level - b.level);
- console.log("Selected players are:");
- selectedPlayers.map((p) => {
- p.print();
- });
- return selectedPlayers;
- }
- propose() {
- console.log("Proposing new games!");
- this.data = [];
- this.addBlockedGames();
- let possibleMatches = this.getPossibleGameCount();
- console.log("Total possible matches are " + possibleMatches + ".");
- this.addInactiveGames(possibleMatches);
- this.addActiveGames(possibleMatches);
- console.log("The matches are:");
- this.data.sort((a, b) => a.id - b.id);
- this.data.map((g) => {
- g.print();
- })
- this.render();
- }
- confirm() {
- let confirmedStatus = this.data[0].status;
- if (confirmedStatus) {
- return;
- }
- console.log("Confirm the proposal for round " + this.round + ".");
- this.round += 1;
- localStorage.setItem("round", this.round);
- for (let i = 0; i < this.data.length; i++) {
- this.data[i].status = true;
- }
- let selectedPlayers = [];
- for (let game of this.data) {
- console.log(game);
- if ( game.teamOne.playerOne.name !== "RESERVED" ){
- selectedPlayers.push(game.teamOne.playerOne.name);
- selectedPlayers.push(game.teamOne.playerTwo.name);
- selectedPlayers.push(game.teamTwo.playerOne.name);
- selectedPlayers.push(game.teamTwo.playerTwo.name);
- }
- }
- this.regulars.incrementGames(selectedPlayers);
- this.guests.incrementGames(selectedPlayers);
- this.store();
- this.render();
- document.getElementById("timer-start").click();
- }
- reset() {
- localStorage.removeItem("round");
- localStorage.removeItem(this.description);
- localStorage.removeItem(this.courts.description);
- localStorage.removeItem(this.guests.description);
- this.regulars.reset();
- document.getElementById("timer-stop").click();
- window.location.reload();
- }
-}
-
-function shuffle(array) {
- for (let i = array.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [array[i], array[j]] = [array[j], array[i]];
- }
- return array;
-}
-
-function addGameBehaviour(g) {
- document.getElementById("propose").addEventListener("click", () => {
- g.propose();
- });
- document.getElementById("confirm").addEventListener("click", () => {
- g.confirm();
- });
- document.getElementById("reset").addEventListener("click", () => {
- g.reset();
- });
-}
-
-const g = new GameManager();
+import "./style.css";
+import { mount } from "svelte";
+import App from "./App.svelte";
+mount(App, { target: document.getElementById("app") });
diff --git a/src/manage.js b/src/manage.js
deleted file mode 100644
index 10a9896..0000000
--- a/src/manage.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import { Court, Guest, Regular } from "./data";
-import { uniqueNamesGenerator, names, adjectives } from 'unique-names-generator';
-
-const config = {
- dictionaries: [adjectives, names],
- style: 'capital',
- length: 2
-}
-
-export class Manager {
- constructor(description, maxCount) {
- this.description = description;
- this.maxCount = maxCount;
- this.data = [];
- this.count = this.data.length;
- let data = JSON.parse(localStorage.getItem(this.description));
- if (data === null || data.length === 0) {
- this.generate();
- return;
- }
- this.load(data);
- }
- generate() {
- for (let i = 0; i < this.maxCount; i++) {
- this.data.push(this.createItem(i));
- }
- console.log("Generated " + this.data.length + " " + this.description);
- this.store();
- this.render();
- }
- load(data) {
- for (let i = 0; i < data.length; i++) {
- this.data.push(this.loadItem(data[i]));
- }
- console.log("Loaded " + this.data.length + " " + this.description);
- this.render();
- }
- store() {
- let stringData = JSON.stringify(this.data);
- localStorage.setItem(this.data[0].description, stringData);
- }
- render() {
- let renderedHTML = this.data
- .map((i) => {
- return i.render();
- })
- .join("");
- let elementId = this.data[0].description + "-list";
- document.getElementById(elementId).innerHTML = renderedHTML;
- this.addBehaviour();
- }
- addBehaviour() {
- for (let idx = 0; idx < this.data.length; ++idx) {
- const elementId = this.data[0].description + "_" + this.data[idx].id;
- const p = document.getElementById(elementId);
- p.addEventListener("click", () => {
- this.data[idx].toggle();
- this.store();
- p.classList.toggle("secondary");
- p.classList.toggle("outline");
- });
- }
- }
- active() {
- return this.data.filter(function (c) {
- return c.status;
- });
- }
- inactive() {
- return this.data.filter(function (c) {
- return !c.status;
- });
- }
-}
-
-export class CourtManager extends Manager {
- constructor() {
- super("Court", 12);
- }
- createItem(i) {
- return new Court(i, randomStatus());
- }
- loadItem(data) {
- let id = Number(data.id);
- let status = Boolean(Number(data.status));
- return new Court(id, status);
- }
-}
-
-export function randomStatus() {
- return Math.random() < 0.5;
-}
-
-function normalize(data) {
- let factor = Number(document.getElementById("skill").value);
- console.log("Normalize skill by a factor of " + factor + ".");
- return data.map(function (p) {
- p.skill = Math.ceil(p.skill / factor);
- return p;
- });
-}
-
-export class GuestManager extends Manager {
- constructor(description, count) {
- if (typeof description === "undefined") {
- description = "Guest";
- }
- if (typeof count === "undefined") {
- count = 18;
- }
- super(description, count);
- }
- createItem(i) {
- return new Guest(i, randomStatus());
- }
- loadItem(data) {
- let id = Number(data.id);
- let status = Boolean(Number(data.status));
- let games = data.games;
- return new Guest(id, status, games);
- }
- active() {
- return normalize(super.active());
- }
- incrementGames(list) {
- for (let index = 0; index < this.data.length; index++) {
- if (list.includes(this.data[index].name)) {
- console.log("Added game count to " + this.data[index].name);
- this.data[index].games += 1;
- }
- }
- this.store();
- }
-}
-
-export function randomLevel() {
- const maxLevel = 10;
- return Math.floor(Math.random() * maxLevel + 1);
-}
-
-export class RegularManager extends GuestManager {
- constructor() {
- super("Regular", 71);
- addLoadBehaviour(this);
- }
- createItem(i) {
- const name = uniqueNamesGenerator(config);
- console.log(name);
- const firstName = name.split("_")[0];
- const lastName = name.split("_")[1];
- return new Regular(i, randomStatus(), randomLevel(), 0, firstName, lastName);
- }
- loadItem(data) {
- const id = Number(data.id);
- const level = Number(data.level);
- const status = Boolean(Number(data.status));
- const games = Number(data.games);
- const firstName = data.firstName;
- const lastName = data.lastName;
- return new Regular(id, status, level, games, firstName, lastName);
- }
- reset() {
- for (let index = 0; index < this.data.length; index++) {
- this.data[index].games = 0;
- this.data[index].status = false;
- }
- }
-}
-
-function parseLineToPlayer(data) {
- const info = data.split(",");
- const id = Number(info[0].trim());
- const firstName = info[1].trim();
- const lastName = info[2].trim();
- const level = Number(info[3].trim());
- return new Regular(id, randomStatus(), level, 0, firstName, lastName);
-}
-
-function parseCsv(data) {
- let lines = data.split("\n");
- let players = [];
- for (let i = 1; i < lines.length; i++) {
- if (lines[i] == undefined || lines[i].trim() == "") {
- continue;
- }
- players.push(parseLineToPlayer(lines[i]));
- }
- console.log("Loaded " + players.length + " Players");
- for (let i = 0; i < players.length; i++) {
- players[i].print();
- }
- return players;
-}
-
-document.getElementById("button-player-load").addEventListener("click", () => {
- document.getElementById("player-load").click();
-});
-
-function addLoadBehaviour(p) {
- const f = document.getElementById("player-load");
- f.addEventListener("change", () => {
- const file = f.files[0];
- let importData;
- console.log("Loading data from " + file.name);
- const reader = new FileReader();
- reader.onload = function (e) {
- importData = reader.result;
- p.data = parseCsv(importData);
- p.store();
- p.render();
- };
- reader.readAsText(file);
- });
-}
diff --git a/src/timer.js b/src/timer.js
deleted file mode 100644
index 9b0a8b9..0000000
--- a/src/timer.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import { Notyf } from "notyf";
-
-const milliSecond = 1000;
-
-export class Timer {
- pointer = 0;
- mins = Number(sessionStorage.getItem("mins")) || 12;
- running = Boolean(Number(sessionStorage.getItem("running"))) || false;
- deadline = Number(sessionStorage.getItem("deadline")) || new Date().getTime();
-
- constructor() {
- this.running ? this.start(false) : this.reset();
- addTimerBehaviour(this);
- }
- store() {
- sessionStorage.setItem("mins", this.mins);
- sessionStorage.setItem("running", Number(this.running));
- sessionStorage.setItem("deadline", this.deadline);
- }
- set(m, s) {
- const timer = document.getElementById("timer-display");
- m = m < 10 ? "0" + m : m;
- s = s < 10 ? "0" + s : s;
- timer.value = `${m}:${s}`;
- }
- reset() {
- clearInterval(this.pointer);
- const mins = Math.floor(this.mins);
- const secs = (this.mins - mins) * 60;
- this.set(mins, secs);
- this.running = false;
- this.store();
- }
- update(ms) {
- const m = Math.floor((ms % (milliSecond * 60 * 60)) / (milliSecond * 60));
- const s = Math.floor((ms % (milliSecond * 60)) / milliSecond);
- this.set(m, s);
- }
- refresh(p) {
- const now = new Date().getTime();
- const msToDeadline = p.deadline - now;
- if (msToDeadline > 0) {
- p.update(msToDeadline);
- return;
- }
- p.reset();
- const n = new Notyf({
- duration: 0,
- position: { x: "center", y: "top" },
- dismissible: true,
- });
- n.error("Round completed");
- }
- start(fresh) {
- if (fresh) {
- const now = new Date().getTime();
- const msToDeadline = this.mins * 60 * 1000;
- this.deadline = now + msToDeadline;
- }
- this.running = true;
- this.store();
- this.pointer = setInterval(this.refresh, 1000, this);
- }
- change(value) {
- this.mins += value;
- if (this.mins < 0.5) {
- this.mins = 0.5;
- }
- this.reset();
- }
-}
-
-function addTimerBehaviour(t) {
- document.getElementById("timer-start").addEventListener("click", () => {
- if (t.running) return;
- t.start(1);
- });
- document.getElementById("timer-stop").addEventListener("click", () => {
- t.reset();
- });
- document.getElementById("timer-inc").addEventListener("click", () => {
- if (!t.running) {
- t.change(0.5);
- }
- });
- document.getElementById("timer-dec").addEventListener("click", () => {
- if (!t.running) {
- t.change(-0.5);
- }
- });
-}
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..d0e6448
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+export default {
+ preprocess: vitePreprocess(),
+};
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..f6e4f70
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,11 @@
+import { defineConfig } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+
+export default defineConfig({
+ plugins: [svelte()],
+ resolve: process.env.VITEST ? { conditions: ["browser"] } : undefined,
+ test: {
+ environment: "node",
+ setupFiles: ["./src/lib/test-setup.js"],
+ },
+});