fix(game): harden playback, persistence, and accessibility

This commit is contained in:
2026-08-03 09:14:00 -04:00
parent ef5ed459ad
commit 60771992ce
16 changed files with 656 additions and 415 deletions
+6 -5
View File
@@ -43,8 +43,9 @@ 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.
- 💾 **Privacy First**: No analytics or personal data are stored. The app maintains only an
anonymous, approximate aggregate of completed games.
- 💾 **Privacy First**: No analytics or personal data are stored. Progress, settings, and stats
stay in your browser; completed games only increment an anonymous aggregate counter. Audio is
provided by SoundCloud.
---
@@ -67,8 +68,8 @@ See it in action:
### Installation
```bash
git clone https://github.com/joshpatra/maisie-peters-heardle.git
cd maisie-peters-heardle
git clone https://github.com/SoPat712/maisie-heardle.git
cd maisie-heardle
npm install
```
@@ -78,7 +79,7 @@ npm install
npm run dev -- --open
```
Visit `http://localhost:5173` in your browser. The game will load the SoundCloud widget and prewarm the daily snippet.
Visit `http://localhost:5173` in your browser. The game loads the daily track through the SoundCloud widget.
### Building for Production
+144 -77
View File
@@ -850,16 +850,16 @@
}
},
"node_modules/@netlify/otel": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-6.0.3.tgz",
"integrity": "sha512-NIjIjB/aItiXKB6+wzSwfSyYqbNsVSzzlEPryxxTC5ZJiYSYSm82wAOcQ+9VdiufQyZO5t8jzHVPULo7a9L9sQ==",
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-6.0.5.tgz",
"integrity": "sha512-FUnQsG+xp5gljmHZgrBgGLYKmKYQlGvrao6gtKenJSPCYTMzsYB9PbFfjIoEvwS+o8DxuWDrHlRDDGU3o9ObhQ==",
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "1.9.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/instrumentation": "^0.217.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-node": "2.7.1"
"@opentelemetry/api": "1.9.1",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/instrumentation": "^0.220.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-trace-node": "2.9.0"
},
"engines": {
"node": "^18.14.0 || >=20.6.1"
@@ -875,18 +875,18 @@
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.217.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz",
"integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==",
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz",
"integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
@@ -896,9 +896,9 @@
}
},
"node_modules/@opentelemetry/context-async-hooks": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz",
"integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz",
"integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==",
"license": "Apache-2.0",
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -908,9 +908,9 @@
}
},
"node_modules/@opentelemetry/core": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
"integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -923,12 +923,12 @@
}
},
"node_modules/@opentelemetry/instrumentation": {
"version": "0.217.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.217.0.tgz",
"integrity": "sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w==",
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz",
"integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.217.0",
"@opentelemetry/api-logs": "0.220.0",
"import-in-the-middle": "^3.0.0",
"require-in-the-middle": "^8.0.0"
},
@@ -940,12 +940,44 @@
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz",
"integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/core": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz",
"integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
@@ -956,13 +988,14 @@
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz",
"integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz",
"integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/core": "2.9.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-trace": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
@@ -972,15 +1005,60 @@
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-node": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz",
"integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==",
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/context-async-hooks": "2.7.1",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-node": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.9.0.tgz",
"integrity": "sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/context-async-hooks": "2.9.0",
"@opentelemetry/core": "2.9.0",
"@opentelemetry/sdk-trace-base": "2.9.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -990,9 +1068,9 @@
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
@@ -1392,9 +1470,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.70.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz",
"integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==",
"version": "2.70.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz",
"integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2007,9 +2085,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2281,15 +2359,6 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-import-attributes": {
"version": "1.9.5",
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
"license": "MIT",
"peerDependencies": {
"acorn": "^8"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
@@ -2395,9 +2464,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2681,7 +2750,6 @@
"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": {
@@ -3174,14 +3242,13 @@
}
},
"node_modules/import-in-the-middle": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz",
"integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.3.tgz",
"integrity": "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==",
"license": "Apache-2.0",
"dependencies": {
"acorn": "^8.15.0",
"acorn-import-attributes": "^1.9.5",
"cjs-module-lexer": "^2.2.0",
"es-module-lexer": "^2.2.0",
"module-details-from-path": "^1.0.4"
},
"engines": {
@@ -3703,9 +3770,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@@ -3862,9 +3929,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -3882,7 +3949,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -4473,9 +4540,9 @@
}
},
"node_modules/tar": {
"version": "7.5.19",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz",
"integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==",
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
+1 -7
View File
@@ -1,11 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
@import 'tailwindcss';
/* load any custom fonts here, e.g.: */
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
font-family: 'Inter', sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
}
-11
View File
@@ -1,5 +1,3 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
type SoundCloudSound = {
artwork_url?: string;
@@ -15,7 +13,6 @@ declare global {
getDuration: (callback: (duration: number) => void) => void;
getCurrentSound: (callback: (sound: SoundCloudSound) => void) => void;
getPosition: (callback: (position: number) => void) => void;
isPaused: (callback: (paused: boolean) => void) => void;
play: () => void;
pause: () => void;
seekTo: (milliseconds: number) => void;
@@ -31,14 +28,6 @@ declare global {
interface Window {
SC?: SoundCloudApi;
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
-2
View File
@@ -4,8 +4,6 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<!-- SoundCloud Widget API -->
<script src="https://w.soundcloud.com/player/api.js"></script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
+311 -286
View File
@@ -20,10 +20,17 @@
tracks,
type Track
} from '$lib/tracks';
import { calculateNextStats, createEmptyStats, type Stats } from '$lib/game-state';
import {
calculateNextStats,
createEmptyStats,
parseSavedGame,
sanitizeStats,
type AttemptInfo,
type SavedGame,
type Stats
} from '$lib/game-state';
const COLORS = {
background: '#ffffff',
panel: '#ffffff',
text: '#171717',
muted: '#5f6268',
@@ -38,6 +45,7 @@
const currentTrack = getDailyTrack(todayKey);
const storageKey = `maisie-heardle:game:${todayKey}:${currentTrack.slug}`;
const statsKey = 'maisie-heardle:stats:v1';
const themeKey = 'maisie-heardle:theme';
const SEGMENT_INCREMENTS = [2, 1, 2, 3, 4, 5];
let total = 0;
@@ -52,19 +60,9 @@
const SEEK_FLICK_TURNS = 3;
const SEEK_FLICK_DURATION = 850;
type AttemptInfo = { status: 'skip' | 'wrong' | 'correct'; title?: string };
type SavedGame = {
attemptInfos: AttemptInfo[];
attemptCount: number;
gameOver: boolean;
message: string;
won: boolean;
statsRecorded: boolean;
};
let attemptInfos: AttemptInfo[] = [];
let attemptCount = 0;
let gameOver = false;
let message = '';
let won = false;
let statsRecorded = false;
let stats: Stats = createEmptyStats(maxAttempts);
@@ -76,8 +74,14 @@
let loading = true;
let widgetError = '';
let widgetReadyTimeout: ReturnType<typeof setTimeout>;
let resumePlaybackTimeout: ReturnType<typeof setTimeout>;
let warmupPlayTimeout: ReturnType<typeof setTimeout>;
let warmupPauseTimeout: ReturnType<typeof setTimeout>;
let warmupSeekTimeout: ReturnType<typeof setTimeout>;
let artworkUrl = '';
let isPlaying = false;
let hasPlayed = false;
let isWarmingUp = false;
let currentPosition = 0;
let progressInterval: ReturnType<typeof setInterval>;
let fullDuration = 0;
@@ -91,14 +95,16 @@
let vinylSeekAnimation: Animation | undefined;
let isVinylSeekAnimating = false;
let seekPointerId: number | undefined;
let seekStartPosition = 0;
let waveformHeights = [4, 4, 4, 4, 4, 4, 4];
let waveformInterval: ReturnType<typeof setInterval>;
let isWarmingUp = false;
let showHowTo = false;
let showInfo = false;
let globalGamesPlayed: number | null = null;
let darkMode = false;
let themeOverride: 'dark' | 'light' | null = null;
let reducedMotion = false;
let userInput = '';
let suggestions: Track[] = [];
let selectedTrack: Track | null = null;
@@ -112,8 +118,7 @@
let timeLeft = '';
let countdownInterval: ReturnType<typeof setInterval>;
let attemptHistoryEl: HTMLDivElement;
let attemptPanelEl: HTMLElement;
let attemptHistoryEl: HTMLOListElement;
let resultPanelEl: HTMLElement;
let gameGridEl: HTMLDivElement;
let gameScrollEl: HTMLDivElement;
@@ -123,9 +128,9 @@
let layoutObserver: ResizeObserver | undefined;
let middleScrollable = false;
let gameScrollFill = false;
let destroyed = false;
const boundaries = segmentDurations.map((ms) => ms / 1000).slice(0, -1);
$: activeLimit = gameOver ? fullDuration || TOTAL_MS : segmentDurations[attemptCount] || TOTAL_MS;
$: progressDuration = gameOver ? fullDuration || TOTAL_MS : TOTAL_MS;
$: fillPercent = Math.min((currentPosition / progressDuration) * 100, 100);
$: unlockedSeconds = Math.round((segmentDurations[attemptCount] || TOTAL_MS) / 1000);
@@ -140,6 +145,11 @@
);
$: canSubmit = Boolean(selectedTrack || exactMatch) && !gameOver && !loading && !widgetError;
$: resultLabel = won ? `${attemptCount}/${maxAttempts}` : `X/${maxAttempts}`;
$: resultMessage = gameOver
? won
? `Correct. It was “${currentTrack.title}.”`
: `Out of tries. It was “${currentTrack.title}.”`
: '';
$: suggestions =
userInput && !selectedTrack
? tracks
@@ -154,20 +164,18 @@
$: if (hydrated) {
saveGame({
attemptInfos,
attemptCount,
gameOver,
message,
won,
statsRecorded
});
}
$: if (typeof window !== 'undefined' && !isVinylSeekAnimating) {
handlePlayStateChange(isPlaying);
handlePlayStateChange(isPlaying, reducedMotion, gameOver);
}
$: if (typeof window !== 'undefined') {
driveWaveform(isPlaying);
driveWaveform(isPlaying && !reducedMotion);
}
$: if (hydrated) {
@@ -211,11 +219,76 @@
}
}
function readStorage(key: string) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function writeStorage(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
return;
}
}
function removeStorage(key: string) {
try {
localStorage.removeItem(key);
} catch {
return;
}
}
function toggleTheme() {
darkMode = !darkMode;
themeOverride = darkMode ? 'dark' : 'light';
writeStorage(themeKey, themeOverride);
}
function handleDialogKeydown(event: KeyboardEvent) {
if (!showHowTo && !showInfo) return;
if (event.key === 'Escape') {
closeDialog();
return;
}
if (event.key !== 'Tab' || !dialogEl) return;
const focusable = Array.from(
dialogEl.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex="0"]')
);
if (!focusable.length) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable.at(-1)!;
if (
event.shiftKey &&
(document.activeElement === first || document.activeElement === dialogEl)
) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
onMount(() => {
darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
const colorPreference = window.matchMedia('(prefers-color-scheme: dark)');
const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)');
const savedTheme = readStorage(themeKey);
themeOverride = savedTheme === 'dark' || savedTheme === 'light' ? savedTheme : null;
darkMode = themeOverride ? themeOverride === 'dark' : colorPreference.matches;
reducedMotion = motionPreference.matches;
stats = loadStats();
loadSavedGame();
const savedVol = localStorage.getItem('heardle-volume');
const savedVol = readStorage('heardle-volume');
if (savedVol !== null) {
const parsedVolume = Number.parseInt(savedVol, 10);
if (Number.isFinite(parsedVolume)) {
@@ -224,9 +297,12 @@
}
hydrated = true;
const colorPreference = window.matchMedia('(prefers-color-scheme: dark)');
const onPreferenceChange = (event: MediaQueryListEvent) => (darkMode = event.matches);
const onPreferenceChange = (event: MediaQueryListEvent) => {
if (!themeOverride) darkMode = event.matches;
};
const onMotionChange = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
colorPreference.addEventListener('change', onPreferenceChange);
motionPreference.addEventListener('change', onMotionChange);
updateTime();
countdownInterval = setInterval(updateTime, 1000);
@@ -238,17 +314,15 @@
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);
window.addEventListener('keydown', handleDialogKeydown);
return () => {
colorPreference.removeEventListener('change', onPreferenceChange);
motionPreference.removeEventListener('change', onMotionChange);
layoutObserver?.disconnect();
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKeydown);
window.removeEventListener('keydown', handleDialogKeydown);
};
});
@@ -273,6 +347,7 @@
async function loadGlobalGamesPlayed() {
try {
const res = await fetch('/api/stats/games-played', { cache: 'no-store' });
if (destroyed) return;
if (res.ok) {
const data = (await res.json()) as { count?: number };
globalGamesPlayed = data.count ?? 0;
@@ -287,7 +362,7 @@
async function recordGlobalGameFinish() {
const countedKey = `maisie-heardle:global-finish-counted:${todayKey}`;
if (localStorage.getItem(countedKey)) {
if (readStorage(countedKey)) {
await loadGlobalGamesPlayed();
return;
}
@@ -297,10 +372,11 @@
method: 'POST',
cache: 'no-store'
});
if (destroyed) return;
if (res.ok) {
const data = (await res.json()) as { count?: number };
globalGamesPlayed = data.count ?? 0;
localStorage.setItem(countedKey, 'true');
writeStorage(countedKey, 'true');
}
} catch (err) {
console.error('Failed to record global games played counter:', err);
@@ -312,6 +388,7 @@
const response = await fetch(
`/api/soundcloud/oembed?url=${encodeURIComponent(currentTrack.url)}`
);
if (destroyed) return;
if (response.ok) {
const data = (await response.json()) as { thumbnailUrl?: string };
if (data.thumbnailUrl) {
@@ -326,6 +403,7 @@
async function initializeWidget() {
try {
await loadSoundCloudApi();
if (destroyed) return;
const soundcloud = window.SC;
if (!soundcloud) throw new Error('SoundCloud player API did not initialize.');
@@ -338,6 +416,7 @@
widgetError = 'SoundCloud took too long to load. Please reload and try again.';
}, 15_000);
} catch (error) {
if (destroyed) return;
loading = false;
widgetError =
error instanceof Error
@@ -347,12 +426,21 @@
}
onDestroy(() => {
destroyed = true;
stopAllTimers();
clearInterval(countdownInterval);
clearTimeout(widgetReadyTimeout);
clearTimeout(resumePlaybackTimeout);
stopWidgetWarmup();
cancelVinylSeekAnimation();
if (widget?.unbind && window.SC?.Widget?.Events) {
Object.values(window.SC.Widget.Events).forEach((eventName) => widget.unbind(eventName));
Object.values(window.SC.Widget.Events).forEach((eventName) => {
try {
widget.unbind(eventName);
} catch {
return;
}
});
}
if (typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(animationFrameId);
@@ -363,11 +451,9 @@
function driveWaveform(playing: boolean) {
clearInterval(waveformInterval);
if (playing) {
// Seed with an initial burst
waveformHeights = waveformHeights.map(() => 4 + Math.random() * 24);
waveformInterval = setInterval(() => {
waveformHeights = waveformHeights.map((prev) => {
// Organic: weighted blend of previous height + new random target
const target = 3 + Math.random() * 28;
const inertia = 0.3 + Math.random() * 0.35;
return prev * inertia + target * (1 - inertia);
@@ -381,15 +467,21 @@
function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
volume = parseInt(target.value);
if (typeof window !== 'undefined') {
localStorage.setItem('heardle-volume', volume.toString());
}
writeStorage('heardle-volume', volume.toString());
if (widget && widget.setVolume) {
widget.setVolume(volume);
}
}
function handlePlayStateChange(playing: boolean) {
function handlePlayStateChange(playing: boolean, shouldReduceMotion: boolean, ended: boolean) {
if (shouldReduceMotion) {
cancelVinylSeekAnimation();
cancelAnimationFrame(animationFrameId);
vinylSpinTransition = 'none';
setVinylRotation(0);
return;
}
if (playing) {
cancelVinylSeekAnimation();
lastFrameTime = 0;
@@ -401,7 +493,7 @@
cancelAnimationFrame(animationFrameId);
if (gameOver) {
if (ended) {
vinylSpinTransition = 'none';
return;
}
@@ -462,41 +554,35 @@
const events = soundcloud.Widget.Events;
widget.bind(events.READY, () => {
if (destroyed) return;
clearTimeout(widgetReadyTimeout);
widget.setVolume(volume);
widget.getDuration((duration: number) => {
if (destroyed) return;
if (!Number.isFinite(duration) || duration <= 0) {
loading = false;
widgetError = 'SoundCloud did not return a playable song duration.';
return;
}
fullDuration = duration;
warmUpWidget();
});
widget.getCurrentSound((sound: { artwork_url?: string }) => {
if (destroyed) return;
if (sound?.artwork_url) {
artworkUrl = sound.artwork_url;
}
});
isWarmingUp = true;
setTimeout(() => {
widget.play();
setTimeout(() => {
widget.pause();
setTimeout(() => {
widget.seekTo(0);
loading = false;
widgetReady = true;
isPlaying = false;
currentPosition = 0;
isWarmingUp = false;
}, 200);
}, 300);
}, 500);
});
widget.bind(events.PLAY, () => {
if (!isWarmingUp && !isPlaying) startPolling();
hasPlayed = true;
if (isWarmingUp) return;
if (!isPlaying) startPolling();
});
widget.bind(events.PAUSE, () => {
if (isWarmingUp) return;
if (isPlaying) stopAllTimers();
});
@@ -521,6 +607,8 @@
if (events.ERROR) {
widget.bind(events.ERROR, () => {
clearTimeout(widgetReadyTimeout);
stopWidgetWarmup();
stopAllTimers();
loading = false;
widgetError = 'SoundCloud could not play todays song. Try reloading the page.';
@@ -528,6 +616,36 @@
}
}
function warmUpWidget() {
isWarmingUp = true;
widget.setVolume(0);
warmupPlayTimeout = setTimeout(() => {
if (destroyed) return;
widget.play();
warmupPauseTimeout = setTimeout(() => {
if (destroyed) return;
widget.pause();
warmupSeekTimeout = setTimeout(() => {
if (destroyed) return;
widget.seekTo(0);
widget.setVolume(volume);
loading = false;
widgetReady = true;
isPlaying = false;
currentPosition = 0;
isWarmingUp = false;
}, 200);
}, 300);
}, 500);
}
function stopWidgetWarmup() {
clearTimeout(warmupPlayTimeout);
clearTimeout(warmupPauseTimeout);
clearTimeout(warmupSeekTimeout);
isWarmingUp = false;
}
function updateTime() {
if (getLocalDateKey() !== todayKey) {
window.location.reload();
@@ -553,7 +671,7 @@
progressInterval = setInterval(() => {
if (!widget) return;
widget.getPosition((position: number) => {
currentPosition = position;
if (!destroyed) currentPosition = position;
});
}, 100);
}
@@ -563,37 +681,21 @@
clearInterval(progressInterval);
}
function ensurePlayState() {
if (!widget) return;
widget.isPaused((paused: boolean) => {
if (!paused && !isPlaying) {
startPolling();
} else if (paused && isPlaying) {
stopAllTimers();
}
});
}
function playSegment(seekToStart = true) {
if (!widgetReady || loading || widgetError) return;
stopAllTimers();
if (seekToStart) {
if (seekToStart && hasPlayed) {
currentPosition = 0;
widget.seekTo(0);
}
widget.play();
startPolling();
hasPlayed = true;
}
function togglePlayPause() {
if (!widgetReady || loading || widgetError) return;
ensurePlayState();
if (isPlaying) {
widget.pause();
} else {
playSegment(!gameOver);
}
if (isPlaying) widget.pause();
else playSegment(!gameOver);
}
function cancelVinylSeekAnimation() {
@@ -616,6 +718,7 @@
}
function spinVinylDuringScrub(deltaPosition: number, fallbackDirection = 1) {
if (reducedMotion) return;
if (Math.abs(deltaPosition) < 16 && Math.abs(fallbackDirection) < 1) return;
cancelVinylSeekAnimation();
@@ -626,6 +729,12 @@
}
function animateVinylSeekToPosition(targetPosition: number, fromPosition: number) {
if (reducedMotion) {
cancelVinylSeekAnimation();
setVinylRotation(0);
return;
}
const fromRotation = vinylRotation;
const deltaPosition = targetPosition - fromPosition;
const direction = deltaPosition === 0 ? 1 : Math.sign(deltaPosition);
@@ -717,6 +826,7 @@
event.preventDefault();
const element = event.currentTarget as HTMLElement;
seekPointerId = event.pointerId;
seekStartPosition = currentPosition;
element.setPointerCapture(event.pointerId);
previewSeekPosition(event.clientX, element);
}
@@ -726,13 +836,28 @@
previewSeekPosition(event.clientX, event.currentTarget as HTMLElement);
}
function releaseSeekPointer(event: PointerEvent) {
const element = event.currentTarget as HTMLElement;
if (element.hasPointerCapture(event.pointerId)) element.releasePointerCapture(event.pointerId);
seekPointerId = undefined;
}
function handleSeekPointerEnd(event: PointerEvent) {
if (seekPointerId !== event.pointerId) return;
const element = event.currentTarget as HTMLElement;
previewSeekPosition(event.clientX, element);
element.releasePointerCapture(event.pointerId);
seekPointerId = undefined;
seekToPosition(currentPosition, { animateVinyl: true });
const nextPosition = currentPosition;
currentPosition = seekStartPosition;
releaseSeekPointer(event);
seekToPosition(nextPosition);
}
function handleSeekPointerCancel(event: PointerEvent) {
if (seekPointerId !== event.pointerId) return;
const nextPosition = currentPosition;
currentPosition = seekStartPosition;
releaseSeekPointer(event);
seekToPosition(nextPosition);
}
function handleSeekKeydown(event: KeyboardEvent) {
@@ -747,37 +872,32 @@
if (nextPosition === undefined) return;
event.preventDefault();
seekToPosition(nextPosition, { animateVinyl: true });
seekToPosition(nextPosition);
}
function rewindSong() {
if (!widgetReady || !gameOver) return;
const wasPlaying = isPlaying;
seekToPosition(0);
if (wasPlaying) setTimeout(() => widget.play(), 150);
}
function seekToPosition(
position: number,
options: { animateVinyl?: boolean; seekWidget?: boolean } = {}
) {
const { animateVinyl = true, seekWidget = true } = options;
function seekToPosition(position: number, animateVinyl = true) {
const duration = fullDuration || TOTAL_MS;
const nextPosition = Math.min(Math.max(position, 0), duration);
const wasPlaying = isPlaying;
const previousPosition = currentPosition;
currentPosition = nextPosition;
if (seekWidget) {
widget.seekTo(nextPosition);
}
widget.seekTo(nextPosition);
if (gameOver) {
syncVinylToPosition(nextPosition, animateVinyl, previousPosition);
}
clearTimeout(resumePlaybackTimeout);
if (wasPlaying) {
setTimeout(() => widget.play(), 75);
resumePlaybackTimeout = setTimeout(() => {
if (!destroyed) widget.play();
}, 75);
}
}
@@ -827,20 +947,11 @@
function finishGame(didWin: boolean) {
won = didWin;
gameOver = true;
message = didWin
? `Correct. It was “${currentTrack.title}.”`
: `Out of tries. It was “${currentTrack.title}.”`;
stopAllTimers();
widget?.pause();
if (didWin) {
currentPosition = 0;
widget?.seekTo(0);
syncVinylToPosition(0, false);
} else {
currentPosition = fullDuration || segmentDurations[attemptCount - 1] || TOTAL_MS;
syncVinylToPosition(currentPosition, false);
}
currentPosition = 0;
widget?.seekTo(0);
setVinylRotation(0);
if (!statsRecorded) {
stats = recordStats(didWin, attemptCount);
@@ -873,11 +984,13 @@
dialogEl?.focus();
}
function closeDialog() {
async function closeDialog() {
const returnFocus = previouslyFocusedElement;
showHowTo = false;
showInfo = false;
previouslyFocusedElement?.focus();
previouslyFocusedElement = null;
await tick();
returnFocus?.focus();
}
async function loadSuggestionArtwork(nextSuggestions: Track[]) {
@@ -902,6 +1015,7 @@
}
})
);
if (destroyed) return;
suggestionArtwork = {
...suggestionArtwork,
@@ -936,7 +1050,8 @@
await navigator.clipboard.writeText(text);
shareMessage = 'Copied result.';
} catch {
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
shareMessage = 'Could not share from this browser.';
}
}
@@ -955,82 +1070,55 @@
function loadSavedGame() {
try {
const saved = localStorage.getItem(storageKey);
const saved = readStorage(storageKey);
if (!saved) return;
const parsed = JSON.parse(saved) as SavedGame;
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.');
const parsed = parseSavedGame(JSON.parse(saved), maxAttempts);
if (!parsed) throw new Error('Saved game state is inconsistent.');
if (
parsed.won &&
normalizeTrackTitle(parsed.attemptInfos.at(-1)?.title ?? '') !==
normalizeTrackTitle(currentTrack.title)
) {
throw new Error('Saved winning answer does not match todays track.');
}
attemptInfos = parsed.attemptInfos;
attemptCount = attemptInfos.length;
gameOver = parsed.gameOver;
won = parsed.won;
statsRecorded = parsed.statsRecorded;
if (gameOver && !statsRecorded) {
stats = recordStats(won, attemptCount);
statsRecorded = true;
void recordGlobalGameFinish();
}
} catch {
attemptInfos = [];
attemptCount = 0;
gameOver = false;
message = '';
won = false;
statsRecorded = false;
try {
localStorage.removeItem(storageKey);
} catch {
// Storage may be unavailable in privacy-restricted browser contexts.
}
removeStorage(storageKey);
}
}
function saveGame(game: SavedGame) {
try {
localStorage.setItem(storageKey, JSON.stringify(game));
} catch (error) {
console.warn('Could not save game progress:', error);
}
writeStorage(storageKey, JSON.stringify(game));
}
function loadStats(): Stats {
try {
const saved = localStorage.getItem(statsKey);
if (!saved) throw new Error('No stats yet.');
const parsed = JSON.parse(saved) as Stats;
return {
played: parsed.played || 0,
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
)
};
const saved = readStorage(statsKey);
return saved ? sanitizeStats(JSON.parse(saved), maxAttempts) : createEmptyStats(maxAttempts);
} catch {
return createEmptyStats(maxAttempts);
}
}
function recordStats(didWin: boolean, attempts: number) {
const nextStats = calculateNextStats(stats, didWin, attempts, todayKey, maxAttempts);
localStorage.setItem(statsKey, JSON.stringify(nextStats));
const nextStats = calculateNextStats(loadStats(), didWin, attempts, todayKey, maxAttempts);
writeStorage(statsKey, JSON.stringify(nextStats));
return nextStats;
}
</script>
@@ -1101,23 +1189,29 @@
<div class="grid grid-cols-3 gap-3 text-center">
<div class="rounded border p-3" style="border-color: {COLORS.primary}">
<div class="text-2xl font-extrabold">{stats.played}</div>
<div class="text-xs uppercase" style="color: {COLORS.muted}">Played</div>
<div class="text-xs uppercase" style="color: {darkMode ? '#bbb' : COLORS.muted}">
Played
</div>
</div>
<div class="rounded border p-3" style="border-color: {COLORS.secondary}">
<div class="text-2xl font-extrabold">
{stats.played ? Math.round((stats.wins / stats.played) * 100) : 0}%
</div>
<div class="text-xs uppercase" style="color: {COLORS.muted}">Wins</div>
<div class="text-xs uppercase" style="color: {darkMode ? '#bbb' : COLORS.muted}">
Wins
</div>
</div>
<div class="rounded border p-3" style="border-color: {COLORS.accent}">
<div class="text-2xl font-extrabold">{stats.streak}</div>
<div class="text-xs uppercase" style="color: {COLORS.muted}">Streak</div>
<div class="text-xs uppercase" style="color: {darkMode ? '#bbb' : COLORS.muted}">
Streak
</div>
</div>
</div>
<p class="mt-5 text-sm leading-6">
Songs belong to {ARTIST_NAME}. This app stores only your local game progress and local stats
in this browser.
Your progress, settings, and stats stay in this browser. Finishing a game only increments an
anonymous aggregate counter. Audio loads from SoundCloud.
</p>
<p class="mt-3 text-sm">
Next track in <strong>{timeLeft}</strong>
@@ -1136,6 +1230,7 @@
{/if}
<main
inert={showHowTo || showInfo}
class={`heardle-page h-[100dvh] overflow-hidden px-3 py-2 sm:px-6 sm:py-4 md:px-8 ${darkMode ? 'heardle-dark' : ''}`}
>
<div class="mx-auto flex h-full min-h-0 max-w-5xl flex-col">
@@ -1155,7 +1250,7 @@
aria-label={darkMode ? 'Use light mode' : 'Use dark mode'}
title={darkMode ? 'Light mode' : 'Dark mode'}
class="deck-icon-button deck-icon-button-alt"
on:click={() => (darkMode = !darkMode)}
on:click={toggleTheme}
>
<Icon src={darkMode ? Sun : Moon} class="h-5 w-5" />
</button>
@@ -1163,7 +1258,10 @@
<div class="deck-title min-w-0 text-center">
<p class="deck-kicker">Daily listening test</p>
<h1 class="truncate text-xl font-extrabold sm:text-2xl">Heardle - {ARTIST_NAME}</h1>
<h1 class="truncate text-xl font-extrabold sm:text-2xl">
<span class="sm:hidden">Maisie Heardle</span>
<span class="hidden sm:inline">Heardle - {ARTIST_NAME}</span>
</h1>
<p class="text-xs">New song in {timeLeft}</p>
</div>
@@ -1220,27 +1318,29 @@
class="game-grid flex min-h-0 flex-col gap-3 sm:gap-4 lg:grid lg:grid-cols-12 lg:items-stretch lg:gap-x-6 lg:gap-y-4"
>
<div class="left-col order-1 flex min-h-0 w-full flex-col lg:col-span-6">
<section
bind:this={attemptPanelEl}
class="attempt-panel min-h-0 w-full"
class:attempt-panel-game-over={gameOver}
>
<section class="attempt-panel min-h-0 w-full" class:attempt-panel-game-over={gameOver}>
<p class="status-strip">
<span class="status-chip">
{gameOver ? 'Answer revealed' : `${unlockedSeconds}s unlocked`}
</span>
<span class="status-note">
{gameOver ? 'Full playback' : `${maxAttempts - attemptCount} guesses left`}
{gameOver
? 'Full playback'
: `${maxAttempts - attemptCount} ${maxAttempts - attemptCount === 1 ? 'guess' : 'guesses'} left`}
</span>
</p>
<div
<ol
bind:this={attemptHistoryEl}
class="attempt-history space-y-1.5 sm:space-y-2"
class="attempt-history list-none space-y-1.5 sm:space-y-2"
aria-label="Guess history"
aria-live="polite"
aria-relevant="additions"
>
{#each attemptInfos as info, index (`${index}-${info.status}-${info.title ?? 'skip'}`)}
<div
<li
role="listitem"
aria-label={`Try ${index + 1}: ${info.status === 'skip' ? 'Skipped' : info.title}`}
class="attempt-row attempt-row-filled flex h-8 items-center justify-between rounded px-3 text-xs font-semibold sm:h-10 sm:text-sm md:h-11"
style="
--attempt-color: {info.status === 'skip'
@@ -1261,20 +1361,20 @@
{:else}{info.title}{/if}
</span>
<span class="flex-shrink-0 text-[10px] uppercase">Try {index + 1}</span>
</div>
</li>
{/each}
{#each remainingAttempts as attemptNumber (attemptNumber)}
<div
<li
role="listitem"
class="attempt-row attempt-row-empty flex h-8 items-center rounded px-3 text-xs sm:h-10 sm:text-sm md:h-11"
>
Attempt {attemptNumber}
</div>
</li>
{/each}
</div>
</ol>
</section>
</div>
<!-- Right Column: Spinning Vinyl Player (Desktop only) -->
<div
bind:this={vinylColEl}
class="vinyl-col order-2 hidden min-h-0 w-full flex-col lg:col-span-6 lg:flex"
@@ -1287,7 +1387,6 @@
border: 1px solid var(--deck-border);
"
>
<!-- Vintage Start/Stop (Play/Pause) Button (top left) -->
<div
class="absolute top-6 left-8 z-40 flex flex-col items-center gap-1 select-none"
style="width: 28px;"
@@ -1299,7 +1398,7 @@
type="button"
on:click={togglePlayPause}
disabled={loading || Boolean(widgetError)}
class="vintage-play-btn flex h-10 w-10 items-center justify-center rounded-full border border-zinc-800 bg-zinc-950 shadow-[inset_0_2px_4px_rgba(0,0,0,0.8)] focus:outline-none"
class="vintage-play-btn flex h-10 w-10 items-center justify-center rounded-full border border-zinc-800 bg-zinc-950 shadow-[inset_0_2px_4px_rgba(0,0,0,0.8)] focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2"
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
title={isPlaying ? 'Pause' : 'Play'}
>
@@ -1343,32 +1442,29 @@
</button>
</div>
<!-- Platter (clickable to toggle play/pause as a fallback) -->
<button
type="button"
on:click={togglePlayPause}
disabled={loading || Boolean(widgetError)}
class="platter absolute z-30 flex h-[400px] w-[400px] cursor-pointer items-center justify-center rounded-full border focus:outline-none"
class="platter absolute z-30 flex h-[400px] w-[400px] cursor-pointer items-center justify-center rounded-full border focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2"
style="
left: 40px;
top: 20px;
background: #0d5b84;
box-shadow: inset 0 4px 10px rgba(0,0,0,0.35), 0 1px 3px rgba(255,255,255,0.08);
border-color: {darkMode ? '#27272a' : '#3f3f46'};
"
border-color: {darkMode ? '#27272a' : '#3f3f46'};
"
aria-label={isPlaying ? 'Pause audio from record' : 'Play audio from record'}
title={isPlaying ? 'Pause' : 'Play'}
>
<!-- Vinyl record wrapper -->
<div
class="relative flex h-[360px] w-[360px] items-center justify-center overflow-hidden rounded-full"
>
<!-- Vinyl record (spins) -->
<div
bind:this={vinylElement}
class="vinyl-record absolute inset-0 flex items-center justify-center rounded-full shadow-lg"
style="transform: {vinylTransform}; transition: {vinylSpinTransition};"
>
<!-- Track separators (wider gaps) -->
<div
class="pointer-events-none absolute inset-[36px] rounded-full border border-black/30"
></div>
@@ -1379,7 +1475,6 @@
class="pointer-events-none absolute inset-[108px] rounded-full border border-black/30"
></div>
<!-- Vinyl Center Label (spins with record) -->
<div
class="vinyl-label absolute top-1/2 left-1/2 z-10 flex h-36 w-36 -translate-x-1/2 -translate-y-1/2 items-center justify-center overflow-hidden rounded-full border-2 border-black/25"
style="background: linear-gradient(135deg, {COLORS.primary}, {COLORS.secondary});"
@@ -1405,13 +1500,11 @@
</div>
</div>
<!-- Stationary light reflection overlay (sheen stays still) -->
<div
class="vinyl-sheen pointer-events-none absolute inset-0 z-20 rounded-full"
></div>
</div>
<!-- Spindle center peg (STATIONARY in center of platter) -->
<div
class="absolute top-1/2 left-1/2 z-30 flex h-5 w-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border shadow-md"
style="
@@ -1419,19 +1512,16 @@
border-color: {darkMode ? '#3f3f46' : '#a1a1aa'};
"
>
<!-- Small center peg tip -->
<div class="h-1.5 w-1.5 rounded-full bg-zinc-900"></div>
</div>
</button>
<!-- Tonearm (S-shaped arm with pivot at 445,70 on a 480x440 canvas) -->
<div class="tonearm pointer-events-none absolute inset-0 z-40 h-full w-full">
<svg
viewBox="0 0 480 440"
class="h-full w-full drop-shadow-[0_8px_12px_rgba(0,0,0,0.45)]"
>
<defs>
<!-- Metallic linear gradient for the chrome tonearm -->
<linearGradient id="chrome-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f3f4f6" />
<stop offset="30%" stop-color="#9ca3af" />
@@ -1439,7 +1529,6 @@
<stop offset="70%" stop-color="#4b5563" />
<stop offset="100%" stop-color="#e5e7eb" />
</linearGradient>
<!-- Darker metallic for pivot and weight -->
<linearGradient id="dark-metal-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#71717a" />
<stop offset="50%" stop-color="#3f3f46" />
@@ -1447,8 +1536,6 @@
</linearGradient>
</defs>
<!-- 1. Tonearm base / Rest cradle (STATIONARY) -->
<!-- Gimbal base -->
<circle
cx="445"
cy="70"
@@ -1458,8 +1545,6 @@
stroke-width="2"
/>
<circle cx="445" cy="70" r="14" fill="#18181b" stroke="#333" stroke-width="1" />
<!-- Arm Rest structure -->
<!-- Post -->
<line
x1="452"
y1="200"
@@ -1469,7 +1554,6 @@
stroke-width="3"
stroke-linecap="round"
/>
<!-- U-clip cradle -->
<path
d="M 444 200 Q 452 206 460 200"
fill="none"
@@ -1478,13 +1562,11 @@
stroke-linecap="round"
/>
<!-- 2. Rotating Tonearm Assembly -->
<g
style="transform: rotate({isPlaying
? '22deg'
: '0deg'}); transform-origin: 445px 70px; transition: transform 750ms cubic-bezier(0.25, 1, 0.5, 1);"
>
<!-- Counterweight shaft (extends behind pivot) -->
<line
x1="445"
y1="70"
@@ -1494,7 +1576,6 @@
stroke-width="4.5"
stroke-linecap="round"
/>
<!-- Counterweight dial ring -->
<rect
x="430"
y="24"
@@ -1505,7 +1586,6 @@
stroke="#111"
stroke-width="1.5"
/>
<!-- Counterweight weight block -->
<rect
x="432"
y="16"
@@ -1517,7 +1597,6 @@
stroke-width="1"
/>
<!-- Metallic 2-segment straight Tonearm tube -->
<path
d="M 445 70 L 456 180 L 445 235"
stroke="url(#chrome-grad)"
@@ -1527,7 +1606,6 @@
stroke-linejoin="round"
/>
<!-- Cartridge collar connector -->
<circle
cx="445"
cy="235"
@@ -1537,15 +1615,12 @@
stroke-width="0.5"
/>
<!-- Headshell (Classic Technics style angled cartridge) -->
<!-- Headshell body -->
<path
d="M 443 235 L 448 235 L 441 257 L 435 255 Z"
fill="#1f2937"
stroke="#111"
stroke-width="0.75"
/>
<!-- Finger lift (curving to the right) -->
<path
d="M 447 241 C 454 241, 456 245, 455 249"
fill="none"
@@ -1553,20 +1628,17 @@
stroke-width="1"
stroke-linecap="round"
/>
<!-- Stylus body (orange accent cartridge tip) -->
<polygon
points="435,255 441,257 439,261 435,260"
fill={COLORS.accent}
stroke="#111"
stroke-width="0.5"
/>
<!-- Tiny metallic stylus tip/needle -->
<line x1="435" y1="260" x2="433" y2="262" stroke="#d1d5db" stroke-width="1" />
</g>
</svg>
</div>
<!-- Dynamic Island Waveform (bottom right) -->
<div
class="pointer-events-none absolute right-8 bottom-6 z-40 flex h-10 w-24 items-center justify-center gap-1.5 select-none"
>
@@ -1583,7 +1655,6 @@
{/each}
</div>
<!-- Vintage Volume Fader (bottom left, vertical) -->
<div
class="absolute bottom-6 left-8 z-40 flex flex-col items-center gap-1 select-none"
style="width: 28px;"
@@ -1641,7 +1712,7 @@
class="line-clamp-2 text-xs sm:text-sm"
style="color: {darkMode ? '#cfc7c1' : COLORS.muted}"
>
{message}
{resultMessage}
</p>
<a
href={`https://song.link/${currentTrack.url}`}
@@ -1686,7 +1757,6 @@
<div
class="progress-rail relative mb-1.5 h-5 w-full overflow-hidden rounded sm:mb-2 sm:h-7"
aria-label="Audio progress"
>
<div
class="absolute top-0 left-0 h-full transition-[width] duration-100"
@@ -1694,8 +1764,8 @@
></div>
<button
type="button"
class="seek-slider absolute inset-0 z-20 h-full w-full cursor-pointer bg-transparent focus:ring-2 focus:outline-none"
style="--tw-ring-color: {COLORS.primary}; touch-action: none;"
class="seek-slider absolute inset-0 z-20 h-full w-full cursor-pointer"
style="touch-action: none;"
aria-label="Seek finished song"
aria-valuemin="0"
aria-valuemax={Math.round((fullDuration || TOTAL_MS) / 1000)}
@@ -1703,10 +1773,11 @@
aria-valuetext={formatTime(currentPosition)}
role="slider"
title="Seek song"
disabled={!widgetReady || !fullDuration}
on:pointerdown={handleSeekPointerDown}
on:pointermove={handleSeekPointerMove}
on:pointerup={handleSeekPointerEnd}
on:pointercancel={handleSeekPointerEnd}
on:pointercancel={handleSeekPointerCancel}
on:keydown={handleSeekKeydown}
></button>
</div>
@@ -1761,7 +1832,6 @@
</div>
{:else}
<div bind:this={playerWrapEl} class="player-wrap flex-shrink-0">
<!-- Control Deck (Audio player controls & input) -->
<section class="control-deck">
{#if widgetError}
<div
@@ -1777,7 +1847,12 @@
<div
class="progress-rail relative mb-1.5 h-5 w-full overflow-hidden rounded sm:mb-2 sm:h-7"
role="progressbar"
aria-label="Audio progress"
aria-valuemin="0"
aria-valuemax={TOTAL_SECONDS}
aria-valuenow={Math.round(currentPosition / 1000)}
aria-valuetext={formatTime(currentPosition)}
>
{#if !gameOver}
{#each segmentDurations as segmentEnd, index (segmentEnd)}
@@ -1808,25 +1883,6 @@
></div>
{/each}
{/if}
{#if gameOver}
<button
type="button"
class="seek-slider absolute inset-0 z-20 h-full w-full cursor-pointer bg-transparent focus:ring-2 focus:outline-none"
style="--tw-ring-color: {COLORS.primary}; touch-action: none;"
aria-label="Seek finished song"
aria-valuemin="0"
aria-valuemax={Math.round((fullDuration || TOTAL_MS) / 1000)}
aria-valuenow={Math.round(currentPosition / 1000)}
aria-valuetext={formatTime(currentPosition)}
role="slider"
title="Seek song"
on:pointerdown={handleSeekPointerDown}
on:pointermove={handleSeekPointerMove}
on:pointerup={handleSeekPointerEnd}
on:pointercancel={handleSeekPointerEnd}
on:keydown={handleSeekKeydown}
></button>
{/if}
</div>
<div
@@ -1838,21 +1894,6 @@
</div>
<div class="mb-3 flex items-center justify-center gap-4 sm:mb-4">
{#if gameOver}
<button
type="button"
on:click={rewindSong}
class="flex h-9 w-9 items-center justify-center rounded-full border-2 transition hover:scale-105 disabled:opacity-50 sm:h-12 sm:w-12 lg:hidden"
style="border-color: {loading ? '#888888' : COLORS.primary}; color: {loading
? '#888888'
: COLORS.primary}"
disabled={loading}
aria-label="Restart song from beginning"
title="Restart song"
>
<Icon src={ArrowPath} class="h-4 w-4 sm:h-6 sm:w-6" />
</button>
{/if}
<button
type="button"
on:click={togglePlayPause}
@@ -2278,10 +2319,16 @@
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.14);
}
.progress-rail:focus-within {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.seek-slider {
padding: 0;
border: 0;
margin: 0;
background: transparent;
}
.share-button {
@@ -2321,30 +2368,6 @@
}
}
/* Spinning Vinyl Record Player Styles */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.waveform-bar.animating {
animation: waveform-bounce ease-in-out infinite;
}
@keyframes waveform-bounce {
0%,
100% {
height: 4px;
}
50% {
height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.heardle-page *,
.heardle-page *::before,
@@ -2356,21 +2379,25 @@
}
}
/* Vintage Vertical Volume Fader */
.vintage-slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
outline: none;
position: absolute;
width: 80px; /* matches fader container height */
height: 20px; /* matches fader container width */
width: 80px;
height: 20px;
margin: 0;
padding: 0;
/* Rotate -90deg so left(0) is bottom, right(100) is top */
transform: rotate(-90deg);
transform-origin: center center;
}
.vintage-slider:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 3px;
}
.vintage-slider::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
@@ -2384,7 +2411,6 @@
appearance: none;
width: 10px;
height: 18px;
/* Gradient horizontal so line appears vertical after rotation */
background: linear-gradient(
90deg,
#e4e4e7 0%,
@@ -2426,7 +2452,6 @@
cursor: pointer;
}
/* Vintage Tactile Play Button */
.vintage-play-btn {
cursor: pointer;
background: #09090b;
+58 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { calculateNextStats, createEmptyStats } from '$lib/game-state';
import {
calculateNextStats,
createEmptyStats,
parseSavedGame,
sanitizeStats
} from '$lib/game-state';
describe('game statistics', () => {
it('starts a streak with a win', () => {
@@ -33,4 +38,56 @@ describe('game statistics', () => {
expect(losing.wins).toBe(1);
expect(losing.distribution).toEqual([1, 0, 0, 0, 0, 0]);
});
it('records at most one result per day', () => {
const first = calculateNextStats(createEmptyStats(6), true, 1, '2026-07-20', 6);
const duplicate = calculateNextStats(first, false, 6, '2026-07-20', 6);
expect(duplicate).toEqual(first);
});
it('sanitizes stored statistics', () => {
expect(
sanitizeStats(
{
played: 2,
wins: 5,
streak: -1,
maxStreak: '4',
distribution: [1, '2', 3],
lastPlayedDate: 'not-a-date'
},
6
)
).toEqual({
played: 2,
wins: 2,
streak: 0,
maxStreak: 0,
distribution: [1, 0, 3, 0, 0, 0],
lastPlayedDate: undefined
});
});
it('restores only consistent saved games', () => {
expect(
parseSavedGame(
{
attemptInfos: [{ status: 'skip' }, { status: 'correct', title: 'Psycho' }],
gameOver: true,
won: true,
statsRecorded: true
},
6
)
).toEqual({
attemptInfos: [{ status: 'skip' }, { status: 'correct', title: 'Psycho' }],
gameOver: true,
won: true,
statsRecorded: true
});
expect(
parseSavedGame({ attemptInfos: [], gameOver: true, won: false, statsRecorded: false }, 6)
).toBeNull();
});
});
+104 -1
View File
@@ -1,4 +1,4 @@
import { getLocalDateKey } from '$lib/tracks';
import { getLocalDateKey, isValidDateKey } from '$lib/tracks';
export type Stats = {
played: number;
@@ -9,6 +9,29 @@ export type Stats = {
lastPlayedDate?: string;
};
export type AttemptInfo = { status: 'skip' | 'wrong' | 'correct'; title?: string };
export type SavedGame = {
attemptInfos: AttemptInfo[];
gameOver: boolean;
won: boolean;
statsRecorded: boolean;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asCount(value: unknown): number {
return Number.isSafeInteger(value) &&
Number(value) >= 0 &&
Number(value) < Number.MAX_SAFE_INTEGER
? Number(value)
: 0;
}
export function createEmptyStats(maxAttempts: number): Stats {
return {
played: 0,
@@ -19,6 +42,84 @@ export function createEmptyStats(maxAttempts: number): Stats {
};
}
export function sanitizeStats(value: unknown, maxAttempts: number): Stats {
const saved = asRecord(value) ?? {};
const played = asCount(saved.played);
const wins = Math.min(asCount(saved.wins), played);
const streak = Math.min(asCount(saved.streak), wins);
const maxStreak = Math.max(streak, Math.min(asCount(saved.maxStreak), wins));
const savedDistribution = Array.isArray(saved.distribution) ? saved.distribution : [];
const lastPlayedDate =
typeof saved.lastPlayedDate === 'string' && isValidDateKey(saved.lastPlayedDate)
? saved.lastPlayedDate
: undefined;
return {
played,
wins,
streak,
maxStreak,
distribution: Array.from({ length: maxAttempts }, (_, index) =>
asCount(savedDistribution[index])
),
lastPlayedDate
};
}
export function parseSavedGame(value: unknown, maxAttempts: number): SavedGame | null {
const saved = asRecord(value);
if (
!saved ||
!Array.isArray(saved.attemptInfos) ||
typeof saved.gameOver !== 'boolean' ||
typeof saved.won !== 'boolean' ||
saved.attemptInfos.length > maxAttempts
) {
return null;
}
const attemptInfos: AttemptInfo[] = [];
for (const value of saved.attemptInfos) {
const attempt = asRecord(value);
if (!attempt) return null;
if (attempt.status === 'skip') {
attemptInfos.push({ status: 'skip' });
continue;
}
if (
(attempt.status === 'wrong' || attempt.status === 'correct') &&
typeof attempt.title === 'string' &&
attempt.title.trim()
) {
attemptInfos.push({ status: attempt.status, title: attempt.title.trim() });
continue;
}
return null;
}
const correctAttempts = attemptInfos.filter((attempt) => attempt.status === 'correct');
const won = saved.won;
const gameOver = saved.gameOver;
const validWin =
gameOver && won && correctAttempts.length === 1 && attemptInfos.at(-1)?.status === 'correct';
const validLoss =
gameOver && !won && correctAttempts.length === 0 && attemptInfos.length === maxAttempts;
const validActiveGame =
!gameOver && !won && correctAttempts.length === 0 && attemptInfos.length < maxAttempts;
if (!validWin && !validLoss && !validActiveGame) return null;
return {
attemptInfos,
gameOver,
won,
statsRecorded: gameOver && saved.statsRecorded === true
};
}
export function calculateNextStats(
stats: Stats,
didWin: boolean,
@@ -26,6 +127,8 @@ export function calculateNextStats(
todayKey: string,
maxAttempts: number
): Stats {
if (stats.lastPlayedDate === todayKey) return stats;
const previousDate = new Date(`${todayKey}T12:00:00`);
previousDate.setDate(previousDate.getDate() - 1);
const playedYesterday = stats.lastPlayedDate === getLocalDateKey(previousDate);
-2
View File
@@ -1,2 +0,0 @@
// src/lib/index.ts
export { default as HeardleGame } from './HeardleGame.svelte';
+1
View File
@@ -13,6 +13,7 @@ describe('daily track selection', () => {
it('rejects invalid date keys', () => {
expect(() => getDailyTrack('07/22/2026')).toThrow('Invalid date key');
expect(() => getDailyTrack('2026-02-29')).toThrow('Invalid date key');
});
it('has unique answer titles and slugs', () => {
+24 -3
View File
@@ -121,12 +121,33 @@ export function getLocalDateKey(date = new Date()) {
return `${year}-${month}-${day}`;
}
export function getDailyTrack(dateKey = getLocalDateKey()) {
function parseDateKey(dateKey: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
if (!match) throw new Error(`Invalid date key: ${dateKey}`);
if (!match) return null;
const [, year, month, day] = match;
const dayNumber = Math.floor(Date.UTC(Number(year), Number(month) - 1, Number(day)) / 86_400_000);
const date = new Date(0);
date.setUTCHours(0, 0, 0, 0);
date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
if (
date.getUTCFullYear() !== Number(year) ||
date.getUTCMonth() !== Number(month) - 1 ||
date.getUTCDate() !== Number(day)
) {
return null;
}
return date;
}
export function isValidDateKey(dateKey: string) {
return parseDateKey(dateKey) !== null;
}
export function getDailyTrack(dateKey = getLocalDateKey()) {
const date = parseDateKey(dateKey);
if (!date) throw new Error(`Invalid date key: ${dateKey}`);
const dayNumber = Math.floor(date.getTime() / 86_400_000);
const cycle = Math.floor(dayNumber / scheduledTracks.length);
const position =
((dayNumber % scheduledTracks.length) + scheduledTracks.length) % scheduledTracks.length;
-2
View File
@@ -1,4 +1,3 @@
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import '../app.css';
</script>
@@ -7,7 +6,6 @@
<title>Heardle - Maisie Peters Edition</title>
</svelte:head>
<!-- Remove bg-black/text-white here so your game can supply its own background -->
<div class="min-h-screen bg-transparent antialiased">
<slot />
</div>
-1
View File
@@ -1,2 +1 @@
// src/routes/+page.ts
export const ssr = false;
+7 -2
View File
@@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ fetch, url }) => {
try {
const parsedUrl = new URL(trackUrl);
if (!ALLOWED_HOSTS.has(parsedUrl.hostname)) {
if (parsedUrl.protocol !== 'https:' || !ALLOWED_HOSTS.has(parsedUrl.hostname)) {
return json({ error: 'Only SoundCloud URLs are supported.' }, { status: 400 });
}
} catch {
@@ -23,7 +23,12 @@ export const GET: RequestHandler = async ({ fetch, url }) => {
oembedUrl.searchParams.set('format', 'json');
oembedUrl.searchParams.set('url', trackUrl);
const response = await fetch(oembedUrl);
let response: Response;
try {
response = await fetch(oembedUrl, { signal: AbortSignal.timeout(8_000) });
} catch {
return json({ error: 'SoundCloud metadata was unavailable.' }, { status: 502 });
}
if (!response.ok) {
return json({ error: 'SoundCloud metadata was unavailable.' }, { status: response.status });
-10
View File
@@ -1,10 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,svelte,ts,js}'],
theme: {
extend: {
// put your custom colors, spacing, etc. here
}
},
plugins: []
};
-5
View File
@@ -11,9 +11,4 @@
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}