mirror of
https://github.com/SoPat712/maisie-heardle.git
synced 2026-08-19 12:12:30 -04:00
fix(game): improve daily reliability and validation
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
@@ -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**: Auto‑detects 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
|
||||
|
||||
Generated
+399
-16
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -19,6 +19,7 @@ declare global {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seekTo: (milliseconds: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
};
|
||||
|
||||
type SoundCloudApi = {
|
||||
|
||||
+138
-53
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user