fix(game): improve daily reliability and validation

This commit is contained in:
2026-07-22 22:48:03 -04:00
parent 90ee33fe65
commit 50a36280f6
13 changed files with 730 additions and 89 deletions
+22
View File
@@ -0,0 +1,22 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run check
- run: npm run lint
- run: npm test
- run: npm run build
+1
View File
@@ -0,0 +1 @@
20
+18 -8
View File
@@ -30,7 +30,7 @@ Made to be modifiable, so you can play this game for any artists you like!
See it in action:
> https://maisie-peters-heardle.joshpatra.me'
> https://maisie-peters-heardle.joshpatra.me
![image](https://github.com/user-attachments/assets/ec9bf8aa-ee41-42a7-9202-4e5e8719ffbc)
@@ -43,7 +43,8 @@ See it in action:
-**Skips & Wrong Guesses**: Skip to unlock the next snippet; wrong guesses also unlock more audio.
-**Countdown**: Live countdown timer until the next daily track.
- 🌙 **Dark Mode**: Autodetects system preference and can be toggled manually.
- 💾 **No Data Collection**: No analytics or personal data stored.
- 💾 **Privacy First**: No analytics or personal data are stored. The app maintains only an
anonymous, approximate aggregate of completed games.
---
@@ -53,7 +54,6 @@ See it in action:
- **[Tailwind CSS](https://tailwindcss.com/)**
- **[SoundCloud Widget API](https://developers.soundcloud.com/docs/api/html5-widget)**
- **[Svelte Hero Icons](https://www.npmjs.com/package/svelte-hero-icons)**
- **[Moment.js](https://momentjs.com/)**
---
@@ -61,8 +61,8 @@ See it in action:
### Prerequisites
- [Node.js](https://nodejs.org/) v16+
- [npm](https://www.npmjs.com/) v8+
- [Node.js](https://nodejs.org/) v20.19+
- [npm](https://www.npmjs.com/) v10+
### Installation
@@ -97,13 +97,15 @@ npm run build && npm run preview
## Configuration
- **Artist & User**
In `src/lib/HeardleGame.svelte`:
In `src/lib/tracks.ts`:
```ts
const ARTIST_NAME = 'Maisie Peters';
const SC_USER = 'maisie-peters';
```
- **Track List**
Edit `const TRACKS_DATA: TrackData[] = [...]` to add, remove, or comment out tracks.
Edit `TRACKS_DATA` in `src/lib/tracks.ts` to add or remove tracks. Run the test suite after
changing the catalog; the published-date assertions are intentional safeguards against
accidentally changing a live answer.
https://github.com/SoPat712/soundcloud-track-importer
@@ -125,7 +127,6 @@ npm run build && npm run preview
│ │ ├── +layout.svelte # App layout & global head
│ │ └── +page.svelte # Root page slot
│ └── app.css # Tailwind imports & custom styles
├── netlify.toml # (Optional) Netlify configuration
├── package.json
└── README.md
```
@@ -140,6 +141,15 @@ npm run build && npm run preview
4. Push: `git push origin feature/YourFeature`
5. Open a Pull Request
Before opening a pull request, run:
```bash
npm run check
npm run lint
npm test
npm run build
```
---
## License
+399 -16
View File
@@ -18,6 +18,7 @@
"@sveltejs/kit": "^2.36.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^26.1.1",
"@types/seedrandom": "^3.0.8",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -32,7 +33,11 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.2.5"
"vite": "^6.2.5",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20.19"
}
},
"node_modules/@envelop/instrumentation": {
@@ -1351,9 +1356,9 @@
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"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"
},
@@ -1387,9 +1392,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.68.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.68.0.tgz",
"integrity": "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g==",
"version": "2.70.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz",
"integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1752,6 +1757,17 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"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/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -1759,6 +1775,13 @@
"dev": true,
"license": "MIT"
},
"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.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1772,6 +1795,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/seedrandom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz",
@@ -1974,9 +2007,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2054,6 +2087,119 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"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/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@whatwg-node/disposablestack": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz",
@@ -2212,6 +2358,16 @@
"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/atomically": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz",
@@ -2239,9 +2395,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2267,6 +2423,16 @@
"node": ">=6"
}
},
"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/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2351,6 +2517,13 @@
"dev": true,
"license": "MIT"
},
"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/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -2504,6 +2677,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@@ -2776,6 +2956,16 @@
"node": ">=4.0"
}
},
"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/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2786,6 +2976,16 @@
"node": ">=0.10.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/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3426,9 +3626,9 @@
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.18",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz",
"integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==",
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -3528,6 +3728,20 @@
"dev": true,
"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/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3620,6 +3834,13 @@
"node": ">=8"
}
},
"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",
@@ -4047,6 +4268,13 @@
"node": ">=8"
}
},
"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/sirv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
@@ -4072,6 +4300,20 @@
"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-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4247,6 +4489,23 @@
"node": ">=18"
}
},
"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.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"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",
@@ -4264,6 +4523,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -4374,6 +4643,13 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4492,6 +4768,96 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"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.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"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/when-exit": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz",
@@ -4514,6 +4880,23 @@
"node": ">= 8"
}
},
"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/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+7 -1
View File
@@ -3,6 +3,9 @@
"private": true,
"version": "0.0.1",
"type": "module",
"engines": {
"node": ">=20.19"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -10,6 +13,7 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
@@ -20,6 +24,7 @@
"@sveltejs/kit": "^2.36.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^26.1.1",
"@types/seedrandom": "^3.0.8",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -34,7 +39,8 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.2.5"
"vite": "^6.2.5",
"vitest": "^4.1.10"
},
"dependencies": {
"@netlify/blobs": "^10.7.9",
+1
View File
@@ -19,6 +19,7 @@ declare global {
play: () => void;
pause: () => void;
seekTo: (milliseconds: number) => void;
setVolume: (volume: number) => void;
};
type SoundCloudApi = {
+138 -53
View File
@@ -20,6 +20,7 @@
tracks,
type Track
} from '$lib/tracks';
import { calculateNextStats, createEmptyStats, type Stats } from '$lib/game-state';
const COLORS = {
background: '#ffffff',
@@ -60,27 +61,13 @@
won: boolean;
statsRecorded: boolean;
};
type Stats = {
played: number;
wins: number;
streak: number;
maxStreak: number;
distribution: number[];
};
let attemptInfos: AttemptInfo[] = [];
let attemptCount = 0;
let gameOver = false;
let message = '';
let won = false;
let statsRecorded = false;
let stats: Stats = {
played: 0,
wins: 0,
streak: 0,
maxStreak: 0,
distribution: Array(maxAttempts).fill(0)
};
let stats: Stats = createEmptyStats(maxAttempts);
let iframeElement: HTMLIFrameElement;
let widget: SoundCloudWidget;
@@ -88,6 +75,7 @@
let volume = 80;
let loading = true;
let widgetError = '';
let widgetReadyTimeout: ReturnType<typeof setTimeout>;
let artworkUrl = '';
let isPlaying = false;
let currentPosition = 0;
@@ -119,6 +107,8 @@
let suggestions: Track[] = [];
let selectedTrack: Track | null = null;
let inputEl: HTMLInputElement;
let dialogEl: HTMLDivElement;
let previouslyFocusedElement: HTMLElement | null = null;
let hydrated = false;
let shareMessage = '';
let suggestionArtwork: Record<string, string> = {};
@@ -149,7 +139,10 @@
{ length: Math.max(maxAttempts - attemptInfos.length, 0) },
(_, index) => attemptInfos.length + index + 1
);
$: canSubmit = Boolean(userInput.trim()) && !gameOver && !loading && !widgetError;
$: exactMatch = tracks.find(
(track) => normalizeTrackTitle(track.title) === normalizeTrackTitle(userInput)
);
$: canSubmit = Boolean(selectedTrack || exactMatch) && !gameOver && !loading && !widgetError;
$: resultLabel = won ? `${attemptCount}/${maxAttempts}` : `X/${maxAttempts}`;
$: suggestions =
userInput && !selectedTrack
@@ -228,7 +221,10 @@
loadSavedGame();
const savedVol = localStorage.getItem('heardle-volume');
if (savedVol !== null) {
volume = parseInt(savedVol);
const parsedVolume = Number.parseInt(savedVol, 10);
if (Number.isFinite(parsedVolume)) {
volume = Math.min(Math.max(parsedVolume, 0), 100);
}
}
hydrated = true;
@@ -246,12 +242,17 @@
layoutObserver = new ResizeObserver(() => updateGameLayout());
}
const onResize = () => updateGameLayout();
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && (showHowTo || showInfo)) closeDialog();
};
window.addEventListener('resize', onResize);
window.addEventListener('keydown', onKeydown);
return () => {
colorPreference.removeEventListener('change', onPreferenceChange);
layoutObserver?.disconnect();
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKeydown);
};
});
@@ -296,7 +297,10 @@
}
try {
const res = await fetch('/api/stats/games-played?increment=1', { cache: 'no-store' });
const res = await fetch('/api/stats/games-played', {
method: 'POST',
cache: 'no-store'
});
if (res.ok) {
const data = (await res.json()) as { count?: number };
globalGamesPlayed = data.count ?? 0;
@@ -331,6 +335,12 @@
widget = soundcloud.Widget(iframeElement);
bindWidgetEvents(soundcloud);
clearTimeout(widgetReadyTimeout);
widgetReadyTimeout = setTimeout(() => {
if (widgetReady) return;
loading = false;
widgetError = 'SoundCloud took too long to load. Please reload and try again.';
}, 15_000);
} catch (error) {
loading = false;
widgetError =
@@ -343,6 +353,7 @@
onDestroy(() => {
stopAllTimers();
clearInterval(countdownInterval);
clearTimeout(widgetReadyTimeout);
cancelVinylSeekAnimation();
window.removeEventListener('pointermove', handleSeekPointerMoveWindow);
window.removeEventListener('pointerup', endSeekDrag);
@@ -428,19 +439,30 @@
'script[src="https://w.soundcloud.com/player/api.js"]'
);
const timeout = window.setTimeout(
() => reject(new Error('SoundCloud took too long to load.')),
10_000
);
const loaded = () => {
window.clearTimeout(timeout);
resolve();
};
const failed = () => {
window.clearTimeout(timeout);
reject(new Error('Failed to load SoundCloud.'));
};
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error('Failed to load SoundCloud.')), {
once: true
});
existing.addEventListener('load', loaded, { once: true });
existing.addEventListener('error', failed, { once: true });
return;
}
const tag = document.createElement('script');
tag.src = 'https://w.soundcloud.com/player/api.js';
tag.async = true;
tag.onload = () => resolve();
tag.onerror = () => reject(new Error('Failed to load SoundCloud.'));
tag.onload = loaded;
tag.onerror = failed;
document.head.appendChild(tag);
});
}
@@ -449,6 +471,7 @@
const events = soundcloud.Widget.Events;
widget.bind(events.READY, () => {
clearTimeout(widgetReadyTimeout);
widget.setVolume(volume);
widget.getDuration((duration: number) => {
fullDuration = duration;
@@ -511,6 +534,10 @@
}
function updateTime() {
if (getLocalDateKey() !== todayKey) {
window.location.reload();
return;
}
const now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
const diff = midnight - now.getTime();
@@ -855,10 +882,7 @@
function submitGuess() {
if (!canSubmit) return;
const exactMatch = tracks.find(
(track) => normalizeTrackTitle(track.title) === normalizeTrackTitle(userInput)
);
const guessedTrack = selectedTrack || exactMatch || suggestions[0];
const guessedTrack = selectedTrack || exactMatch;
if (!guessedTrack) return;
attemptCount += 1;
@@ -911,6 +935,31 @@
shareMessage = '';
}
function handleGuessInput() {
if (
selectedTrack &&
normalizeTrackTitle(selectedTrack.title) !== normalizeTrackTitle(userInput)
) {
selectedTrack = null;
}
}
async function openDialog(dialog: 'howTo' | 'info') {
previouslyFocusedElement =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
showHowTo = dialog === 'howTo';
showInfo = dialog === 'info';
await tick();
dialogEl?.focus();
}
function closeDialog() {
showHowTo = false;
showInfo = false;
previouslyFocusedElement?.focus();
previouslyFocusedElement = null;
}
async function loadSuggestionArtwork(nextSuggestions: Track[]) {
const slugKey = nextSuggestions.map((track) => track.slug).join('|');
if (slugKey === requestedSuggestionSlugs) return;
@@ -990,19 +1039,51 @@
if (!saved) return;
const parsed = JSON.parse(saved) as SavedGame;
attemptInfos = Array.isArray(parsed.attemptInfos) ? parsed.attemptInfos : [];
attemptCount = Math.min(Math.max(parsed.attemptCount || 0, 0), maxAttempts);
attemptInfos = Array.isArray(parsed.attemptInfos)
? parsed.attemptInfos
.filter(
(attempt): attempt is AttemptInfo =>
attempt?.status === 'skip' ||
attempt?.status === 'wrong' ||
attempt?.status === 'correct'
)
.slice(0, maxAttempts)
: [];
attemptCount = Math.min(
Math.max(
Number.isInteger(parsed.attemptCount) ? parsed.attemptCount : attemptInfos.length,
0
),
maxAttempts
);
gameOver = Boolean(parsed.gameOver);
message = parsed.message || '';
won = Boolean(parsed.won);
statsRecorded = Boolean(parsed.statsRecorded);
if (attemptCount !== attemptInfos.length || (won && !gameOver)) {
throw new Error('Saved game state is inconsistent.');
}
} catch {
localStorage.removeItem(storageKey);
attemptInfos = [];
attemptCount = 0;
gameOver = false;
message = '';
won = false;
statsRecorded = false;
try {
localStorage.removeItem(storageKey);
} catch {
// Storage may be unavailable in privacy-restricted browser contexts.
}
}
}
function saveGame(game: SavedGame) {
localStorage.setItem(storageKey, JSON.stringify(game));
try {
localStorage.setItem(storageKey, JSON.stringify(game));
} catch (error) {
console.warn('Could not save game progress:', error);
}
}
function loadStats(): Stats {
@@ -1015,33 +1096,20 @@
wins: parsed.wins || 0,
streak: parsed.streak || 0,
maxStreak: parsed.maxStreak || 0,
lastPlayedDate:
typeof parsed.lastPlayedDate === 'string' ? parsed.lastPlayedDate : undefined,
distribution: Array.from(
{ length: maxAttempts },
(_, index) => parsed.distribution?.[index] || 0
)
};
} catch {
return {
played: 0,
wins: 0,
streak: 0,
maxStreak: 0,
distribution: Array(maxAttempts).fill(0)
};
return createEmptyStats(maxAttempts);
}
}
function recordStats(didWin: boolean, attempts: number) {
const nextStats = {
...stats,
played: stats.played + 1,
wins: stats.wins + (didWin ? 1 : 0),
streak: didWin ? stats.streak + 1 : 0,
maxStreak: didWin ? Math.max(stats.maxStreak, stats.streak + 1) : stats.maxStreak,
distribution: [...stats.distribution]
};
if (didWin) nextStats.distribution[Math.max(Math.min(attempts - 1, maxAttempts - 1), 0)] += 1;
const nextStats = calculateNextStats(stats, didWin, attempts, todayKey, maxAttempts);
localStorage.setItem(statsKey, JSON.stringify(nextStats));
return nextStats;
}
@@ -1050,9 +1118,11 @@
{#if showHowTo}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div
bind:this={dialogEl}
role="dialog"
aria-modal="true"
aria-labelledby="how-to-title"
tabindex="-1"
class="w-full max-w-md rounded-lg border p-6 shadow-2xl"
style="background: {darkMode ? '#1f1f1f' : COLORS.panel}; color: {darkMode
? '#fffaf7'
@@ -1064,7 +1134,7 @@
type="button"
aria-label="Close how to play"
class="rounded-full p-1 transition hover:scale-105"
on:click={() => (showHowTo = false)}
on:click={closeDialog}
>
<Icon src={XMark} class="h-6 w-6" />
</button>
@@ -1081,9 +1151,11 @@
{#if showInfo}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div
bind:this={dialogEl}
role="dialog"
aria-modal="true"
aria-labelledby="info-title"
tabindex="-1"
class="w-full max-w-lg rounded-lg border p-6 shadow-2xl"
style="background: {darkMode ? '#1f1f1f' : COLORS.panel}; color: {darkMode
? '#fffaf7'
@@ -1100,7 +1172,7 @@
type="button"
aria-label="Close info"
class="rounded-full p-1 transition hover:scale-105"
on:click={() => (showInfo = false)}
on:click={closeDialog}
>
<Icon src={XMark} class="h-6 w-6" />
</button>
@@ -1154,7 +1226,7 @@
aria-label="Game information and stats"
title="Info and stats"
class="deck-icon-button"
on:click={() => (showInfo = true)}
on:click={() => openDialog('info')}
>
<Icon src={InformationCircle} class="h-5 w-5" />
</button>
@@ -1198,7 +1270,7 @@
aria-label="How to play"
title="How to play"
class="deck-icon-button"
on:click={() => (showHowTo = true)}
on:click={() => openDialog('howTo')}
>
<Icon src={QuestionMarkCircle} class="h-5 w-5" />
</button>
@@ -1602,6 +1674,7 @@
<div class="relative flex h-20 w-8 items-center justify-center">
<input
type="range"
aria-label="Volume"
min="0"
max="100"
step="1"
@@ -1888,6 +1961,7 @@
type="text"
placeholder="Type a song title..."
bind:value={userInput}
on:input={handleGuessInput}
on:keydown={onInputKeydown}
on:focus={() => (selectedTrack = null)}
autocomplete="off"
@@ -2339,6 +2413,17 @@
}
}
@media (prefers-reduced-motion: reduce) {
.heardle-page *,
.heardle-page *::before,
.heardle-page *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Vintage Vertical Volume Fader */
.vintage-slider {
-webkit-appearance: none;
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { calculateNextStats, createEmptyStats } from '$lib/game-state';
describe('game statistics', () => {
it('starts a streak with a win', () => {
const stats = calculateNextStats(createEmptyStats(6), true, 2, '2026-07-20', 6);
expect(stats).toMatchObject({
played: 1,
wins: 1,
streak: 1,
maxStreak: 1,
lastPlayedDate: '2026-07-20'
});
expect(stats.distribution).toEqual([0, 1, 0, 0, 0, 0]);
});
it('continues a streak only on consecutive days', () => {
const first = calculateNextStats(createEmptyStats(6), true, 1, '2026-07-20', 6);
const consecutive = calculateNextStats(first, true, 3, '2026-07-21', 6);
const afterGap = calculateNextStats(consecutive, true, 1, '2026-07-23', 6);
expect(consecutive.streak).toBe(2);
expect(afterGap.streak).toBe(1);
expect(afterGap.maxStreak).toBe(2);
});
it('resets a streak after a loss', () => {
const winning = calculateNextStats(createEmptyStats(6), true, 1, '2026-07-20', 6);
const losing = calculateNextStats(winning, false, 6, '2026-07-21', 6);
expect(losing.streak).toBe(0);
expect(losing.wins).toBe(1);
expect(losing.distribution).toEqual([1, 0, 0, 0, 0, 0]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { getLocalDateKey } from '$lib/tracks';
export type Stats = {
played: number;
wins: number;
streak: number;
maxStreak: number;
distribution: number[];
lastPlayedDate?: string;
};
export function createEmptyStats(maxAttempts: number): Stats {
return {
played: 0,
wins: 0,
streak: 0,
maxStreak: 0,
distribution: Array(maxAttempts).fill(0)
};
}
export function calculateNextStats(
stats: Stats,
didWin: boolean,
attempts: number,
todayKey: string,
maxAttempts: number
): Stats {
const previousDate = new Date(`${todayKey}T12:00:00`);
previousDate.setDate(previousDate.getDate() - 1);
const playedYesterday = stats.lastPlayedDate === getLocalDateKey(previousDate);
const nextStreak = didWin ? (playedYesterday ? stats.streak + 1 : 1) : 0;
const distribution = Array.from(
{ length: maxAttempts },
(_, index) => stats.distribution[index] || 0
);
if (didWin) {
distribution[Math.max(Math.min(attempts - 1, maxAttempts - 1), 0)] += 1;
}
return {
...stats,
played: stats.played + 1,
wins: stats.wins + (didWin ? 1 : 0),
streak: nextStreak,
maxStreak: Math.max(stats.maxStreak, nextStreak),
distribution,
lastPlayedDate: todayKey
};
}
+1 -1
View File
@@ -24,7 +24,7 @@ async function writeLocalCount(count: number): Promise<number> {
async function readNetlifyCount(): Promise<number> {
const store = getStore({ name: STORE_NAME, consistency: 'strong' });
const value = await store.get(COUNTER_KEY);
const value = await store.get(COUNTER_KEY, { type: 'text' });
if (!value) return 0;
const count = Number.parseInt(value, 10);
return Number.isFinite(count) ? count : 0;
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { getDailyTrack, getLocalDateKey, normalizeTrackTitle, tracks } from '$lib/tracks';
describe('daily track selection', () => {
it('returns the same track for the same calendar date', () => {
expect(getDailyTrack('2026-07-22')).toEqual(getDailyTrack('2026-07-22'));
});
it('keeps published dates stable', () => {
expect(getDailyTrack('2026-07-22').slug).toBe('this-is-on-you-1');
expect(getDailyTrack('2026-07-23').slug).toBe('body-better');
});
it('rejects invalid date keys', () => {
expect(() => getDailyTrack('07/22/2026')).toThrow('Invalid date key');
});
it('has unique answer titles and slugs', () => {
expect(new Set(tracks.map((track) => track.slug)).size).toBe(tracks.length);
expect(new Set(tracks.map((track) => normalizeTrackTitle(track.title))).size).toBe(
tracks.length
);
});
});
describe('local date keys', () => {
it('formats dates without depending on UTC conversion', () => {
expect(getLocalDateKey(new Date(2026, 6, 2, 23, 30))).toBe('2026-07-02');
});
});
+18 -6
View File
@@ -100,13 +100,20 @@ export const TRACKS_DATA: TrackData[] = [
{ title: 'Worst of You', slug: 'worst-of-you' }
];
export const tracks: Track[] = TRACKS_DATA.map(({ title, slug }) => ({
const scheduledTracks: Track[] = TRACKS_DATA.map(({ title, slug }) => ({
title,
slug,
artist: ARTIST_NAME,
url: `https://soundcloud.com/${SC_USER}/${slug}`
}));
// Keep duplicate SoundCloud uploads in the established daily rotation so a
// catalog cleanup cannot change a live answer, but show each answer only once
// in the guess list.
export const tracks: Track[] = Array.from(
new Map(scheduledTracks.map((track) => [normalizeTrackTitle(track.title), track])).values()
);
export function getLocalDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -115,10 +122,15 @@ export function getLocalDateKey(date = new Date()) {
}
export function getDailyTrack(dateKey = getLocalDateKey()) {
const dayNumber = Math.floor(new Date(`${dateKey}T00:00:00`).getTime() / 86_400_000);
const cycle = Math.floor(dayNumber / tracks.length);
const position = dayNumber % tracks.length;
const shuffled = tracks.map((_, index) => index);
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
if (!match) throw new Error(`Invalid date key: ${dateKey}`);
const [, year, month, day] = match;
const dayNumber = Math.floor(Date.UTC(Number(year), Number(month) - 1, Number(day)) / 86_400_000);
const cycle = Math.floor(dayNumber / scheduledTracks.length);
const position =
((dayNumber % scheduledTracks.length) + scheduledTracks.length) % scheduledTracks.length;
const shuffled = scheduledTracks.map((_, index) => index);
const rng = seedrandom(`maisie-heardle:${cycle}`);
for (let index = shuffled.length - 1; index > 0; index -= 1) {
@@ -126,7 +138,7 @@ export function getDailyTrack(dateKey = getLocalDateKey()) {
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
}
return tracks[shuffled[position]];
return scheduledTracks[shuffled[position]];
}
export function normalizeTrackTitle(title: string) {
+8 -4
View File
@@ -1,9 +1,7 @@
import { getGamesPlayedCount } from '$lib/server/games-played';
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
const increment = url.searchParams.get('increment') === '1';
async function respond(increment: boolean) {
try {
const count = await getGamesPlayedCount(increment);
@@ -18,4 +16,10 @@ export const GET: RequestHandler = async ({ url }) => {
} catch {
return json({ error: 'Counter unavailable.' }, { status: 502 });
}
};
}
export const GET: RequestHandler = async () => respond(false);
// This is intentionally anonymous. It is an approximate aggregate rather than
// an anti-abuse or user-tracking mechanism.
export const POST: RequestHandler = async () => respond(true);