mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
88 Commits
embeddings
...
592fa917cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
592fa917cd | ||
|
|
ad1774e6b0 | ||
|
|
e1a665ba63 | ||
|
|
f3c9feaf97 | ||
|
|
a5cf248e29 | ||
|
|
f9f3f3de12 | ||
|
|
abab886d1a | ||
|
|
acc82af7cd | ||
|
|
fc674d6008 | ||
|
|
087a476df8 | ||
|
|
ba45cbdc37 | ||
|
|
1de436534c | ||
|
|
3bcd7ea266 | ||
|
|
7ecc84ffbc | ||
|
|
3d78353a90 | ||
|
|
bd36f29419 | ||
|
|
3036264d44 | ||
|
|
6554ef8f41 | ||
|
|
c3307b3920 | ||
|
|
19646c72ca | ||
|
|
e592751a1c | ||
|
|
77cc83841d | ||
|
|
e8b2d2e35e | ||
|
|
c50cccd3d3 | ||
|
|
638a789a0f | ||
|
|
e815e15ce5 | ||
|
|
e6081c28f1 | ||
|
|
2de4e9e2a7 | ||
|
|
ea7a848125 | ||
|
|
1719e0bbbf | ||
|
|
885adad756 | ||
|
|
1b96919acd | ||
|
|
8ef2a41a69 | ||
|
|
ac6ec051d4 | ||
|
|
a12c7d011a | ||
|
|
77ffd6fbee | ||
|
|
f6652e59a7 | ||
|
|
290fb06fe9 | ||
|
|
df8a9e62bb | ||
|
|
730b65201a | ||
|
|
78440cfbc3 | ||
|
|
482ee0a83a | ||
|
|
342bd323a1 | ||
|
|
95725ab4fe | ||
|
|
06ff0820ce | ||
|
|
3cf95152e6 | ||
|
|
c04899d419 | ||
|
|
6161ef75a1 | ||
|
|
9d7399769a | ||
|
|
b1591229ba | ||
|
|
96024d5048 | ||
|
|
86f81cf9dd | ||
|
|
24a5fdbb80 | ||
|
|
dfe1c40a8a | ||
|
|
dfe784b744 | ||
|
|
6bced13543 | ||
|
|
7d93ead70c | ||
|
|
4c82aa078b | ||
|
|
2058149207 | ||
|
|
9406498cc9 | ||
|
|
3947e8adb0 | ||
|
|
244113671e | ||
|
|
5b9b2f76f4 | ||
|
|
f7ec0742e1 | ||
|
|
d797b980ea | ||
|
|
2bd86d37a1 | ||
|
|
33d6fae446 | ||
|
|
ff228fb547 | ||
|
|
d21ca9d687 | ||
|
|
2df97f66bf | ||
|
|
b1420a3e4f | ||
|
|
fe9cc09df6 | ||
|
|
55a9fd59ea | ||
|
|
0ee3d8a4d0 | ||
|
|
6365cfb363 | ||
|
|
860839fd75 | ||
|
|
e4b946ec8c | ||
|
|
b80c18c2aa | ||
|
|
8c488d27df | ||
|
|
77d6254a2c | ||
|
|
7fbed528f8 | ||
|
|
cec85be7c9 | ||
|
|
c50336ab5f | ||
|
|
03645f0452 | ||
|
|
cb11d793f6 | ||
|
|
ac1db94b0d | ||
|
|
1b1bc7bd3c | ||
|
|
0f6870344f |
11
.claude/settings.json
Normal file
11
.claude/settings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./secrets/**)",
|
||||
"Read(./config/credentials.json)",
|
||||
"Read(./build)",
|
||||
"Read(./embeddings**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
DATABASE_URL=example.db
|
||||
|
||||
PUBLIC_SITE_URL=https://bibdle.com
|
||||
|
||||
# nodemailer
|
||||
SMTP_USERNAME=email@example.com
|
||||
SMTP_TOKEN=TOKEN
|
||||
SMTP_SERVER=smtp.example.com
|
||||
SMTP_PORT=port
|
||||
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
|
||||
|
||||
# sign in with Discord
|
||||
|
||||
# sign in with google
|
||||
|
||||
# sign in with apple
|
||||
AUTH_SECRET=your-random-secret-here
|
||||
APPLE_ID=com.yourcompany.yourapp.client
|
||||
APPLE_TEAM_ID=your-team-id
|
||||
APPLE_KEY_ID=your-key-id
|
||||
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
|
||||
your-private-key-here
|
||||
-----END PRIVATE KEY-----"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -27,6 +27,8 @@ vite.config.ts.timestamp-*
|
||||
|
||||
llms-*
|
||||
|
||||
embeddings*
|
||||
*bible.xml
|
||||
engwebu_usfx.xml
|
||||
embeddings-cache-L12.json
|
||||
embeddings-cache-L6.json
|
||||
|
||||
deploy.log
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
EnglishNKJBible.xml
|
||||
GreekModern1904Bible.xml
|
||||
engwebu_usfx.xml
|
||||
46
CLAUDE.md
46
CLAUDE.md
@@ -4,7 +4,33 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
|
||||
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book, etc.) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
|
||||
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
(Make sure you use the Svelte agent to execute these commands)
|
||||
|
||||
## Available MCP Tools:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -19,23 +45,23 @@ Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
npm run dev
|
||||
bun run dev
|
||||
|
||||
# Type checking
|
||||
npm run check
|
||||
npm run check:watch
|
||||
bun run check
|
||||
bun run check:watch
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
bun run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
bun run preview
|
||||
|
||||
# Database operations
|
||||
npm run db:push # Push schema changes to database
|
||||
npm run db:generate # Generate migrations
|
||||
npm run db:migrate # Run migrations
|
||||
npm run db:studio # Open Drizzle Studio GUI
|
||||
bun run db:push # Push schema changes to database
|
||||
bun run db:generate # Generate migrations (DO NOT RUN)
|
||||
bun run db:migrate # Run migrations (DO NOT RUN)
|
||||
bun run db:studio # Open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -24143,7 +24143,7 @@
|
||||
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
||||
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
||||
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
||||
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
|
||||
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.”</verse>
|
||||
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
||||
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
||||
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
||||
@@ -25056,7 +25056,7 @@
|
||||
<chapter number="3">
|
||||
<verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse>
|
||||
<verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiner’s fire And like launderers’ soap.</verse>
|
||||
<verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, And purge them as gold and silver, That they may offer to the LordAn offering in righteousness.</verse>
|
||||
<verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, and purge them as gold and silver, that they may offer to the Lord an offering in righteousness.</verse>
|
||||
<verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse>
|
||||
<verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse>
|
||||
<verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse>
|
||||
@@ -33616,4 +33616,4 @@
|
||||
</chapter>
|
||||
</book>
|
||||
</testament>
|
||||
</bible>
|
||||
</bible>
|
||||
|
||||
12
README.md
12
README.md
@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
bunx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
bunx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
@@ -19,10 +19,10 @@ npx sv create my-app
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
bun run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
bun run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
@@ -30,9 +30,9 @@ npm run dev -- --open
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
bun run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
You can preview the production build with `bun run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
51
bun.lock
51
bun.lock
@@ -6,27 +6,26 @@
|
||||
"name": "bibdle",
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"xml2js": "^0.6.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^22.19.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"svelte": "^5.48.5",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vite": "^7.3.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -187,11 +186,11 @@
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
|
||||
|
||||
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.4.0", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ=="],
|
||||
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.2", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.49.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ=="],
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.50.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="],
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
||||
|
||||
@@ -229,13 +228,15 @@
|
||||
|
||||
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
|
||||
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
|
||||
|
||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||
|
||||
@@ -263,7 +264,7 @@
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="],
|
||||
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
|
||||
|
||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||
|
||||
@@ -273,6 +274,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
@@ -303,7 +306,7 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
|
||||
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
||||
|
||||
@@ -421,6 +424,8 @@
|
||||
|
||||
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onnx-proto": ["onnx-proto@4.0.4", "", { "dependencies": { "protobufjs": "^6.8.8" } }, "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA=="],
|
||||
@@ -497,9 +502,9 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svelte": ["svelte@5.46.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw=="],
|
||||
"svelte": ["svelte@5.48.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NB3o70OxfmnE5UPyLr8uH3IV02Q43qJVAuWigYmsSOYsS0s/rHxP0TF81blG0onF/xkhNvZw4G8NfzIX+By5ZQ=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="],
|
||||
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
@@ -523,7 +528,7 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
|
||||
@@ -551,8 +556,12 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@types/better-sqlite3/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
|
||||
|
||||
"prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
26
deploy.sh
Executable file
26
deploy.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUN=$(which bun)
|
||||
|
||||
echo "Pulling latest changes..."
|
||||
PULL_OUTPUT=$(git pull)
|
||||
echo "$PULL_OUTPUT"
|
||||
if [ "$PULL_OUTPUT" = "Already up to date." ]; then
|
||||
echo "Nothing to deploy."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Installing dependencies..."
|
||||
$BUN i
|
||||
|
||||
echo "Building..."
|
||||
$BUN --bun run build
|
||||
|
||||
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||
echo "Restarting service ($SERVICE_NAME)..."
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
|
||||
echo "Done!"
|
||||
11
drizzle.test.config.ts
Normal file
11
drizzle.test.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
if (!process.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: { url: process.env.TEST_DATABASE_URL },
|
||||
verbose: true,
|
||||
strict: true
|
||||
});
|
||||
10
drizzle/0002_outstanding_hiroim.sql
Normal file
10
drizzle/0002_outstanding_hiroim.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `email` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint
|
||||
CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`);
|
||||
275
drizzle/meta/0002_snapshot.json
Normal file
275
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||
"prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d",
|
||||
"tables": {
|
||||
"daily_completions": {
|
||||
"name": "daily_completions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anonymous_id": {
|
||||
"name": "anonymous_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guess_count": {
|
||||
"name": "guess_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"anonymous_id_date_idx": {
|
||||
"name": "anonymous_id_date_idx",
|
||||
"columns": [
|
||||
"anonymous_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"date_idx": {
|
||||
"name": "date_idx",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"date_guess_idx": {
|
||||
"name": "date_guess_idx",
|
||||
"columns": [
|
||||
"date",
|
||||
"guess_count"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"daily_completions_anonymous_id_date_unique": {
|
||||
"name": "daily_completions_anonymous_id_date_unique",
|
||||
"columns": [
|
||||
"anonymous_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"daily_verses": {
|
||||
"name": "daily_verses",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"book_id": {
|
||||
"name": "book_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verse_text": {
|
||||
"name": "verse_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"daily_verses_date_unique": {
|
||||
"name": "daily_verses_date_unique",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"apple_id": {
|
||||
"name": "apple_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_private": {
|
||||
"name": "is_private",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_apple_id_unique": {
|
||||
"name": "user_apple_id_unique",
|
||||
"columns": [
|
||||
"apple_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@
|
||||
"when": 1765934144883,
|
||||
"tag": "0000_clumsy_impossible_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770266674489,
|
||||
"tag": "0001_loose_kree",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1770961427714,
|
||||
"tag": "0002_outstanding_hiroim",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
29
package.json
29
package.json
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "bibdle",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "3.0.0alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "bun --bun vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"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": "bun test",
|
||||
"test:watch": "bun test --watch",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
@@ -18,24 +20,23 @@
|
||||
"devDependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^22.19.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"svelte": "^5.48.5",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
|
||||
31
scripts/analyze_top_users.sh
Executable file
31
scripts/analyze_top_users.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# analyze_top_users.sh
|
||||
# Analyzes the daily_completions table to find the top 10 anonymous IDs by completion count
|
||||
|
||||
# Set database path from argument or default to dev.db
|
||||
DB_PATH="${1:-dev.db}"
|
||||
|
||||
# Check if database file exists
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "Error: Database file not found: $DB_PATH"
|
||||
echo "Usage: $0 [database_path]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the analysis query
|
||||
sqlite3 "$DB_PATH" <<EOF
|
||||
.mode column
|
||||
.headers on
|
||||
.width 36 16 16 17
|
||||
|
||||
SELECT
|
||||
anonymous_id,
|
||||
COUNT(*) as completion_count,
|
||||
MIN(date) as first_completion,
|
||||
MAX(date) as latest_completion
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id
|
||||
ORDER BY completion_count DESC
|
||||
LIMIT 10;
|
||||
EOF
|
||||
@@ -2,18 +2,15 @@ import Database from 'bun:sqlite';
|
||||
|
||||
// Database path - adjust if your database is located elsewhere
|
||||
const dbPath = process.env.DATABASE_URL || './local.db';
|
||||
|
||||
console.log(`Connecting to database: ${dbPath}`);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Query all rows from daily_completions
|
||||
const query = db.query(`
|
||||
SELECT date, guess_count
|
||||
FROM daily_completions
|
||||
ORDER BY date
|
||||
SELECT date, guess_count
|
||||
FROM daily_completions
|
||||
ORDER BY date
|
||||
`);
|
||||
|
||||
const rows = query.all() as { date: string; guess_count: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -50,4 +47,60 @@ const overallAvg = (totalGuesses / totalCompletions).toFixed(2);
|
||||
console.log('--------------|-------------|-------------------');
|
||||
console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`);
|
||||
|
||||
db.close();
|
||||
// Calculate correlation between avg_guesses and completions
|
||||
function calculateCorrelation(data: { avgGuesses: number; completions: number }[]): number {
|
||||
const n = data.length;
|
||||
if (n < 2) return 0;
|
||||
|
||||
const avgX = data.reduce((sum, d) => sum + d.avgGuesses, 0) / n;
|
||||
const avgY = data.reduce((sum, d) => sum + d.completions, 0) / n;
|
||||
|
||||
let numerator = 0;
|
||||
let sumXSquared = 0;
|
||||
let sumYSquared = 0;
|
||||
|
||||
for (const d of data) {
|
||||
const xDiff = d.avgGuesses - avgX;
|
||||
const yDiff = d.completions - avgY;
|
||||
numerator += xDiff * yDiff;
|
||||
sumXSquared += xDiff * xDiff;
|
||||
sumYSquared += yDiff * yDiff;
|
||||
}
|
||||
|
||||
const denominator = Math.sqrt(sumXSquared * sumYSquared);
|
||||
return denominator === 0 ? 0 : numerator / denominator;
|
||||
}
|
||||
|
||||
// Prepare data for correlation analysis
|
||||
const allData = Array.from(dateStats.entries()).map(([date, stats]) => ({
|
||||
date,
|
||||
avgGuesses: stats.total / stats.count,
|
||||
completions: stats.count
|
||||
}));
|
||||
|
||||
// Split into pre and post marketing periods
|
||||
const marketingStartDate = '2026-01-08';
|
||||
const preMarketing = allData.filter(d => d.date < marketingStartDate);
|
||||
const postMarketing = allData.filter(d => d.date >= marketingStartDate);
|
||||
|
||||
console.log('\n=== Correlation Analysis ===\n');
|
||||
|
||||
const allCorrelation = calculateCorrelation(allData);
|
||||
console.log(`Overall correlation (avg_guesses vs completions): ${allCorrelation.toFixed(3)}`);
|
||||
|
||||
if (preMarketing.length >= 2) {
|
||||
const preCorrelation = calculateCorrelation(preMarketing);
|
||||
console.log(`Pre-marketing correlation (before ${marketingStartDate}): ${preCorrelation.toFixed(3)} (n=${preMarketing.length} days)`);
|
||||
}
|
||||
|
||||
if (postMarketing.length >= 2) {
|
||||
const postCorrelation = calculateCorrelation(postMarketing);
|
||||
console.log(`Post-marketing correlation (${marketingStartDate} onward): ${postCorrelation.toFixed(3)} (n=${postMarketing.length} days)`);
|
||||
}
|
||||
|
||||
console.log('\nInterpretation:');
|
||||
console.log(' r close to -1: Strong negative correlation (easier verses → more completions)');
|
||||
console.log(' r close to 0: No correlation');
|
||||
console.log(' r close to +1: Strong positive correlation (harder verses → more completions)');
|
||||
|
||||
db.close();
|
||||
20
scripts/clear-today-verse.sh
Executable file
20
scripts/clear-today-verse.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Clear today's verse from daily_verses table
|
||||
DB_PATH="dev.db"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Deleting verse for date: $TODAY"
|
||||
|
||||
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Successfully deleted verse for $TODAY"
|
||||
|
||||
# Show remaining verses in table
|
||||
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
|
||||
echo "Remaining verses in database: $COUNT"
|
||||
else
|
||||
echo "✗ Failed to delete verse"
|
||||
exit 1
|
||||
fi
|
||||
34
scripts/daily_completions_report.sh
Executable file
34
scripts/daily_completions_report.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
DB_PATH="./local.db"
|
||||
|
||||
# Check if database exists
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "Error: Database not found at $DB_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Query for daily completions on 2026-02-01 with ranking
|
||||
echo "Daily Completions for 2026-02-01"
|
||||
echo "================================="
|
||||
echo ""
|
||||
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
|
||||
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
|
||||
|
||||
# Execute query with custom column mode
|
||||
sqlite3 "$DB_PATH" <<SQL
|
||||
.mode column
|
||||
.headers off
|
||||
.width 12 10 6
|
||||
SELECT
|
||||
SUBSTR(anonymous_id, 1, 10) as anon_id,
|
||||
guess_count,
|
||||
RANK() OVER (ORDER BY guess_count ASC) as rank
|
||||
FROM daily_completions
|
||||
WHERE date = '2026-02-01'
|
||||
ORDER BY rank, guess_count;
|
||||
SQL
|
||||
|
||||
echo ""
|
||||
echo "Total entries:"
|
||||
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"
|
||||
41
scripts/dedup-completions.ts
Normal file
41
scripts/dedup-completions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import path from 'path';
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice(5) : dbUrl;
|
||||
const db = new Database(path.resolve(dbPath));
|
||||
|
||||
const duplicates = db.query(`
|
||||
SELECT anonymous_id, date, COUNT(*) as count
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id, date
|
||||
HAVING count > 1
|
||||
`).all() as { anonymous_id: string; date: string; count: number }[];
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log('No duplicates found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${duplicates.length} duplicate group(s):`);
|
||||
|
||||
const deleteStmt = db.query(`
|
||||
DELETE FROM daily_completions
|
||||
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||
AND id NOT IN (
|
||||
SELECT id FROM daily_completions
|
||||
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
`);
|
||||
|
||||
for (const { anonymous_id, date, count } of duplicates) {
|
||||
deleteStmt.run({ $anonymous_id: anonymous_id, $date: date });
|
||||
console.log(` ${anonymous_id} / ${date}: kept earliest, deleted ${count - 1} row(s) (had ${count})`);
|
||||
}
|
||||
|
||||
console.log('Done.');
|
||||
db.close();
|
||||
75
scripts/deduplicate-completions.ts
Normal file
75
scripts/deduplicate-completions.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import Database from 'bun:sqlite';
|
||||
|
||||
// Database path - adjust if your database is located elsewhere
|
||||
const dbPath = Bun.env.DATABASE_URL || './local.db';
|
||||
console.log(`Connecting to database: ${dbPath}`);
|
||||
const db = new Database(dbPath);
|
||||
|
||||
interface DuplicateGroup {
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface Completion {
|
||||
id: string;
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
guess_count: number;
|
||||
completed_at: number;
|
||||
}
|
||||
|
||||
console.log('Finding duplicates...\n');
|
||||
|
||||
// Find all (anonymous_id, date) pairs with duplicates
|
||||
const duplicatesQuery = db.query<DuplicateGroup, []>(`
|
||||
SELECT anonymous_id, date, COUNT(*) as count
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id, date
|
||||
HAVING count > 1
|
||||
`);
|
||||
|
||||
const duplicates = duplicatesQuery.all();
|
||||
console.log(`Found ${duplicates.length} duplicate groups\n`);
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log('No duplicates to clean up!');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
|
||||
// Process each duplicate group
|
||||
for (const dup of duplicates) {
|
||||
// Get all completions for this (anonymous_id, date) pair
|
||||
const completionsQuery = db.query<Completion, [string, string]>(`
|
||||
SELECT id, anonymous_id, date, guess_count, completed_at
|
||||
FROM daily_completions
|
||||
WHERE anonymous_id = ? AND date = ?
|
||||
ORDER BY completed_at ASC
|
||||
`);
|
||||
|
||||
const completions = completionsQuery.all(dup.anonymous_id, dup.date);
|
||||
console.log(` ${dup.anonymous_id} on ${dup.date}: ${completions.length} entries`);
|
||||
|
||||
// Keep the first (earliest completion), delete the rest
|
||||
const toKeep = completions[0];
|
||||
const toDelete = completions.slice(1);
|
||||
|
||||
console.log(` Keeping: ${toKeep.id} (completed at ${new Date(toKeep.completed_at * 1000).toISOString()})`);
|
||||
|
||||
const deleteQuery = db.query('DELETE FROM daily_completions WHERE id = ?');
|
||||
|
||||
for (const comp of toDelete) {
|
||||
console.log(` Deleting: ${comp.id} (completed at ${new Date(comp.completed_at * 1000).toISOString()})`);
|
||||
deleteQuery.run(comp.id);
|
||||
totalDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Deduplication complete!`);
|
||||
console.log(`Total records deleted: ${totalDeleted}`);
|
||||
console.log(`Unique completions preserved: ${duplicates.length}`);
|
||||
|
||||
db.close();
|
||||
26
scripts/seed-fake-completions.sh
Executable file
26
scripts/seed-fake-completions.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Seed the database with 10 fake completions with random anonymous_ids
|
||||
# Useful for testing streak percentile and stats features
|
||||
|
||||
DB_PATH="dev.db"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
NOW=$(date +%s)
|
||||
|
||||
echo "Seeding 10 fake completions for date: $TODAY"
|
||||
|
||||
for i in {1..50}; do
|
||||
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
ANON_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
GUESS_COUNT=$(( (RANDOM % 6) + 1 )) # 1–6 guesses
|
||||
|
||||
sqlite3 "$DB_PATH" "
|
||||
INSERT OR IGNORE INTO daily_completions (id, anonymous_id, date, guess_count, completed_at)
|
||||
VALUES ('$ID', '$ANON_ID', '$TODAY', $GUESS_COUNT, $NOW);
|
||||
"
|
||||
|
||||
echo " [$i] anon=$ANON_ID guesses=$GUESS_COUNT"
|
||||
done
|
||||
|
||||
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '$TODAY';")
|
||||
echo "✓ Done. Total completions for $TODAY: $TOTAL"
|
||||
41
scripts/test-share-text.ts
Normal file
41
scripts/test-share-text.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||
import { generateShareText } from '../src/lib/utils/share';
|
||||
import { bibleBooks } from '../src/lib/types/bible';
|
||||
|
||||
const NUM_VERSES = 10;
|
||||
|
||||
for (let i = 0; i < NUM_VERSES; i++) {
|
||||
const verse = await fetchRandomVerse();
|
||||
|
||||
// Build a fake "solved in N guesses" scenario with some wrong guesses first
|
||||
const correctBook = bibleBooks.find((b) => b.id === verse.bookId)!;
|
||||
const wrongBook = bibleBooks.find((b) => b.id !== verse.bookId)!;
|
||||
const guessCount = Math.floor(Math.random() * 5) + 1;
|
||||
const guesses = [
|
||||
...Array(guessCount - 1).fill(null).map(() => ({
|
||||
book: wrongBook,
|
||||
testamentMatch: wrongBook.testament === correctBook.testament,
|
||||
sectionMatch: wrongBook.section === correctBook.section,
|
||||
adjacent: Math.abs(wrongBook.order - correctBook.order) === 1,
|
||||
})),
|
||||
{ book: correctBook, testamentMatch: true, sectionMatch: true, adjacent: false },
|
||||
];
|
||||
|
||||
const fakeStreak = Math.random() > 0.5 ? Math.floor(Math.random() * 14) + 2 : 0;
|
||||
|
||||
const shareText = generateShareText({
|
||||
guesses,
|
||||
correctBookId: verse.bookId,
|
||||
dailyVerseDate: new Date().toISOString().slice(0, 10),
|
||||
chapterCorrect: guessCount === 1 && Math.random() > 0.5,
|
||||
isLoggedIn: Math.random() > 0.5,
|
||||
streak: fakeStreak > 0 ? fakeStreak : undefined,
|
||||
origin: 'https://bibdle.com',
|
||||
verseText: verse.verseText,
|
||||
});
|
||||
|
||||
console.log(`\n── Verse ${i + 1}: ${verse.reference} ──`);
|
||||
console.log(`RAW: ${verse.verseText}`);
|
||||
console.log('─'.repeat(40));
|
||||
console.log(shareText);
|
||||
}
|
||||
10
scripts/test-verse-snippets.ts
Normal file
10
scripts/test-verse-snippets.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||
import { getVerseSnippet } from '../src/lib/utils/share';
|
||||
|
||||
const NUM_VERSES = 10;
|
||||
|
||||
for (let i = 0; i < NUM_VERSES; i++) {
|
||||
const verse = await fetchRandomVerse();
|
||||
|
||||
console.log(getVerseSnippet(verse.verseText));
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
4
src/lib/assets/Bluesky_Logo.svg
Normal file
4
src/lib/assets/Bluesky_Logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="600" height="530" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="#1185fe"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 745 B |
4
src/lib/assets/Twitter_Logo.svg
Normal file
4
src/lib/assets/Twitter_Logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 248 204">
|
||||
<path fill="#1d9bf0" d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
236
src/lib/components/AuthModal.svelte
Normal file
236
src/lib/components/AuthModal.svelte
Normal file
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { browser } from '$app/environment';
|
||||
import Container from './Container.svelte';
|
||||
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
anonymousId = ''
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
anonymousId: string;
|
||||
} = $props();
|
||||
|
||||
let mode = $state<'signin' | 'signup'>('signin');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state('');
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
|
||||
function resetForm() {
|
||||
email = '';
|
||||
password = '';
|
||||
firstName = '';
|
||||
lastName = '';
|
||||
error = '';
|
||||
success = '';
|
||||
}
|
||||
|
||||
function switchMode() {
|
||||
mode = mode === 'signin' ? 'signup' : 'signin';
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isOpen = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
loading = true;
|
||||
error = '';
|
||||
success = '';
|
||||
}
|
||||
|
||||
function handleResult(event: any) {
|
||||
loading = false;
|
||||
const result = event.result;
|
||||
|
||||
if (result.type === 'success') {
|
||||
if (result.data?.success) {
|
||||
success = mode === 'signin' ? 'Signed in successfully!' : 'Account created successfully!';
|
||||
setTimeout(() => {
|
||||
if (browser) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 1000);
|
||||
} else if (result.data?.error) {
|
||||
error = result.data.error;
|
||||
}
|
||||
} else if (result.type === 'failure') {
|
||||
error = result.data?.error || 'An error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<Container class="w-full max-w-md p-6 relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">
|
||||
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/auth/apple">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center my-4">
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
<span class="px-3 text-sm text-white/60">or</span>
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
|
||||
use:enhance={({ formData }) => {
|
||||
if (anonymousId) {
|
||||
formData.append('anonymousId', anonymousId);
|
||||
}
|
||||
handleSubmit();
|
||||
return handleResult;
|
||||
}}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#if mode === 'signup'}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="block text-sm font-medium text-white mb-1">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
bind:value={firstName}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="block text-sm font-medium text-white mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
bind:value={lastName}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-white mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
bind:value={email}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-white mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={password}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="••••••••"
|
||||
minlength="6"
|
||||
/>
|
||||
{#if mode === 'signup'}
|
||||
<p class="text-xs text-white/80 mt-1">Minimum 6 characters</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p class="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<p class="text-sm text-green-600">{success}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full mt-6 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
|
||||
</span>
|
||||
{:else}
|
||||
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-white">
|
||||
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchMode}
|
||||
class="text-blue-300 hover:text-blue-200 font-medium ml-1"
|
||||
>
|
||||
{mode === 'signin' ? 'Create one' : 'Sign in'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
{/if}
|
||||
39
src/lib/components/Button.svelte
Normal file
39
src/lib/components/Button.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
variant?: "primary" | "google" | "apple" | "secondary" | "danger";
|
||||
onclick?: () => void;
|
||||
class?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
variant = "primary",
|
||||
onclick,
|
||||
class: className = "",
|
||||
type = "button",
|
||||
}: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
"bg-blue-500 hover:bg-blue-600 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||
google: "bg-white hover:bg-gray-50 text-gray-700 border-gray-500 shadow-md hover:shadow-lg",
|
||||
apple: "bg-black hover:bg-gray-900 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||
secondary:
|
||||
"bg-white/50 hover:bg-white/70 text-gray-700 border-gray-500 shadow-sm hover:shadow-md backdrop-blur-sm",
|
||||
danger: "bg-red-500 hover:bg-red-600 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
{onclick}
|
||||
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
|
||||
variant
|
||||
]} {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
222
src/lib/components/ChapterGuess.svelte
Normal file
222
src/lib/components/ChapterGuess.svelte
Normal file
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { browser } from "$app/environment";
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
interface Props {
|
||||
reference: string;
|
||||
bookId: string;
|
||||
onCompleted?: () => void;
|
||||
}
|
||||
|
||||
let { reference, bookId, onCompleted }: Props = $props();
|
||||
|
||||
// Parse the chapter from the reference (e.g., "John 3:16" -> 3)
|
||||
function parseChapterFromReference(ref: string): number {
|
||||
const match = ref.match(/\s(\d+):/);
|
||||
return match ? parseInt(match[1], 10) : 1;
|
||||
}
|
||||
|
||||
// Get the number of chapters for a book
|
||||
function getChapterCount(bookId: string): number {
|
||||
const chapterCounts: Record<string, number> = {
|
||||
GEN: 50,
|
||||
EXO: 40,
|
||||
LEV: 27,
|
||||
NUM: 36,
|
||||
DEU: 34,
|
||||
JOS: 24,
|
||||
JDG: 21,
|
||||
RUT: 4,
|
||||
"1SA": 31,
|
||||
"2SA": 24,
|
||||
"1KI": 22,
|
||||
"2KI": 25,
|
||||
"1CH": 29,
|
||||
"2CH": 36,
|
||||
EZR: 10,
|
||||
NEH: 13,
|
||||
EST: 10,
|
||||
JOB: 42,
|
||||
PSA: 150,
|
||||
PRO: 31,
|
||||
ECC: 12,
|
||||
SNG: 8,
|
||||
ISA: 66,
|
||||
JER: 52,
|
||||
LAM: 5,
|
||||
EZK: 48,
|
||||
DAN: 12,
|
||||
HOS: 14,
|
||||
JOL: 3,
|
||||
AMO: 9,
|
||||
OBA: 1,
|
||||
JON: 4,
|
||||
MIC: 7,
|
||||
NAM: 3,
|
||||
HAB: 3,
|
||||
ZEP: 3,
|
||||
HAG: 2,
|
||||
ZEC: 14,
|
||||
MAL: 4,
|
||||
MAT: 28,
|
||||
MRK: 16,
|
||||
LUK: 24,
|
||||
JHN: 21,
|
||||
ACT: 28,
|
||||
ROM: 16,
|
||||
"1CO": 16,
|
||||
"2CO": 13,
|
||||
GAL: 6,
|
||||
EPH: 6,
|
||||
PHP: 4,
|
||||
COL: 4,
|
||||
"1TH": 5,
|
||||
"2TH": 3,
|
||||
"1TI": 6,
|
||||
"2TI": 4,
|
||||
TIT: 3,
|
||||
PHM: 1,
|
||||
HEB: 13,
|
||||
JAS: 5,
|
||||
"1PE": 5,
|
||||
"2PE": 3,
|
||||
"1JN": 5,
|
||||
"2JN": 1,
|
||||
"3JN": 1,
|
||||
JUD: 1,
|
||||
REV: 22,
|
||||
};
|
||||
return chapterCounts[bookId] || 1;
|
||||
}
|
||||
|
||||
// Generate 4 random chapter options including the correct one
|
||||
function generateChapterOptions(
|
||||
correctChapter: number,
|
||||
totalChapters: number,
|
||||
): number[] {
|
||||
const options = new Set<number>();
|
||||
options.add(correctChapter);
|
||||
|
||||
if (totalChapters >= 4) {
|
||||
while (options.size < 4) {
|
||||
const randomChapter =
|
||||
Math.floor(Math.random() * totalChapters) + 1;
|
||||
options.add(randomChapter);
|
||||
}
|
||||
} else {
|
||||
while (options.size < 4) {
|
||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||
options.add(randomChapter);
|
||||
}
|
||||
}
|
||||
return Array.from(options).sort(() => Math.random() - 0.5);
|
||||
}
|
||||
|
||||
let correctChapter = $derived(parseChapterFromReference(reference));
|
||||
let totalChapters = $derived(getChapterCount(bookId));
|
||||
let chapterOptions = $state<number[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (chapterOptions.length === 0) {
|
||||
chapterOptions = generateChapterOptions(
|
||||
correctChapter,
|
||||
totalChapters,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let selectedChapter = $state<number | null>(null);
|
||||
let hasAnswered = $state(false);
|
||||
|
||||
// Load saved state from localStorage
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const key = `bibdle-chapter-guess-${reference}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
selectedChapter = data.selectedChapter;
|
||||
hasAnswered = data.hasAnswered;
|
||||
chapterOptions = data.chapterOptions ?? [];
|
||||
}
|
||||
});
|
||||
|
||||
// Save state to localStorage whenever options are generated or answer given
|
||||
$effect(() => {
|
||||
if (!browser || chapterOptions.length === 0) return;
|
||||
const key = `bibdle-chapter-guess-${reference}`;
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ selectedChapter, hasAnswered, chapterOptions }),
|
||||
);
|
||||
});
|
||||
|
||||
function handleChapterSelect(chapter: number) {
|
||||
if (hasAnswered) return;
|
||||
selectedChapter = chapter;
|
||||
hasAnswered = true;
|
||||
if (onCompleted) {
|
||||
onCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
let isCorrect = $derived(
|
||||
selectedChapter !== null && selectedChapter === correctChapter,
|
||||
);
|
||||
</script>
|
||||
|
||||
<Container
|
||||
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 dark:from-amber-900/40 dark:to-yellow-900/30 text-gray-800 dark:text-gray-100 shadow-md"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||
Bonus Challenge
|
||||
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||
>— guess the chapter for an even higher grade</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||
{#each chapterOptions as chapter (chapter)}
|
||||
<button
|
||||
onclick={() => handleChapterSelect(chapter)}
|
||||
disabled={hasAnswered}
|
||||
class={`
|
||||
w-20 h-20 sm:w-24 sm:h-24 text-2xl sm:text-3xl font-bold rounded-xl
|
||||
transition-all duration-300 border-2
|
||||
${
|
||||
hasAnswered
|
||||
? chapter === correctChapter
|
||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||
: selectedChapter === chapter
|
||||
? isCorrect
|
||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||
: "bg-red-400 text-white border-red-500"
|
||||
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
|
||||
: "bg-white/80 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 text-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 hover:shadow-md cursor-pointer"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{chapter}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if hasAnswered}
|
||||
<p
|
||||
class="text-xl sm:text-2xl font-bold mb-2"
|
||||
class:text-green-600={isCorrect}
|
||||
class:text-red-600={!isCorrect}
|
||||
>
|
||||
{isCorrect ? "✓ Correct!" : "✗ Incorrect"}
|
||||
</p>
|
||||
<p class="text-sm opacity-80">
|
||||
The verse is from chapter {correctChapter}
|
||||
</p>
|
||||
{#if isCorrect}
|
||||
<p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
16
src/lib/components/Container.svelte
Normal file
16
src/lib/components/Container.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: className = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="inline-flex flex-col items-center bg-white/10 dark:bg-white/5 backdrop-blur-sm rounded-2xl border border-white/20 dark:border-white/10 shadow-sm {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -2,67 +2,43 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let timeUntilNext = $state("");
|
||||
let newVerseReady = $state(false);
|
||||
let intervalId: number | null = null;
|
||||
let targetTime = 0;
|
||||
|
||||
function calculateTimeUntilFivePM(): string {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
|
||||
// Set target to 5:00 PM today
|
||||
target.setHours(17, 0, 0, 0);
|
||||
|
||||
// If it's already past 5:00 PM, set target to tomorrow 5:00 PM
|
||||
if (now.getTime() >= target.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
|
||||
const diff = target.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
return "00:00:00";
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function calculateTimeUntilMidnight(): string {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
|
||||
// Set target to midnight today
|
||||
function initTarget() {
|
||||
const target = new Date();
|
||||
target.setHours(0, 0, 0, 0);
|
||||
|
||||
// If it's already past midnight, set target to tomorrow midnight
|
||||
if (now.getTime() >= target.getTime()) {
|
||||
if (Date.now() >= target.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
|
||||
const diff = target.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
return "00:00:00";
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
targetTime = target.getTime();
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
timeUntilNext = calculateTimeUntilMidnight();
|
||||
const diff = targetTime - Date.now();
|
||||
|
||||
if (diff <= 0) {
|
||||
newVerseReady = true;
|
||||
timeUntilNext = "";
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initTarget();
|
||||
updateTimer();
|
||||
intervalId = window.setInterval(updateTimer, 1000);
|
||||
});
|
||||
@@ -74,17 +50,33 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="text-center py-12">
|
||||
<div class="w-full flex flex-col flex-1">
|
||||
<div
|
||||
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
||||
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm w-full flex-1"
|
||||
>
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
{#if newVerseReady}
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mt-2"
|
||||
>
|
||||
(refresh page to see the new verse)
|
||||
</p>
|
||||
{:else}
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p
|
||||
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
|
||||
>
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
83
src/lib/components/Credits.svelte
Normal file
83
src/lib/components/Credits.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||
</script>
|
||||
|
||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm"
|
||||
>
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300 font-bold"
|
||||
>
|
||||
A project by George Powell & Silent Summit Co.
|
||||
</p>
|
||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
For questions, comments, job opportunities, or cash donations,
|
||||
please email <a
|
||||
class="text-blue-400"
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
>george@silentsummit.co</a
|
||||
>
|
||||
</p> -->
|
||||
<!-- <p class="text-lg font-triodion font-black text-gray-800 tabular-nums">
|
||||
|
||||
</p> -->
|
||||
|
||||
<!-- Bluesky Social Media Button -->
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center justify-center gap-6">
|
||||
<a
|
||||
href="https://bsky.app/profile/snail.city"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Bluesky"
|
||||
data-umami-event="Bluesky clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div>
|
||||
|
||||
<a
|
||||
href="https://x.com/pupperpowell"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Twitter"
|
||||
data-umami-event="Twitter clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
|
||||
>
|
||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div>
|
||||
|
||||
<a
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Email clicked")}
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
122
src/lib/components/DevButtons.svelte
Normal file
122
src/lib/components/DevButtons.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
|
||||
let { anonymousId }: { anonymousId: string | null } = $props();
|
||||
|
||||
let seeding = $state(false);
|
||||
|
||||
async function seedHistory() {
|
||||
if (!browser || !anonymousId || seeding) return;
|
||||
seeding = true;
|
||||
try {
|
||||
const response = await fetch("/api/dev/seed-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ anonymousId })
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(
|
||||
`Seeded! Inserted: ${result.inserted?.join(", ")}. Skipped (already exist): ${result.skipped?.join(", ") || "none"}`
|
||||
);
|
||||
} catch {
|
||||
alert("Failed to seed history");
|
||||
} finally {
|
||||
seeding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocalStorage() {
|
||||
if (!browser) return;
|
||||
// Clear all bibdle-related localStorage items
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith("bibdle-")) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
// Reload the page to reset state
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pt-24 pb-4">
|
||||
<div class="border-t-2 border-gray-400"></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => alert("About page coming soon!")}
|
||||
class="w-full md:w-auto py-4 md:py-2"
|
||||
>
|
||||
About Bibdle / FAQs
|
||||
</Button>
|
||||
<Button
|
||||
variant="google"
|
||||
onclick={() => alert("Google sign-in coming soon!")}
|
||||
class="w-full md:w-auto py-4 md:py-2"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
<Button
|
||||
variant="apple"
|
||||
onclick={() => alert("Apple sign-in coming soon!")}
|
||||
class="w-full md:w-auto py-4 md:py-2"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => alert("Patreon coming soon!")}
|
||||
class="w-full md:w-auto py-4 md:py-2"
|
||||
>
|
||||
Become a Patron
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
onclick={clearLocalStorage}
|
||||
class="w-full py-4 md:py-2"
|
||||
>
|
||||
Clear LocalStorage
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={seedHistory}
|
||||
disabled={seeding}
|
||||
class="w-full py-4 md:py-2"
|
||||
>
|
||||
{seeding ? "Seeding..." : "Seed 10 Days of History"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
</script>
|
||||
|
||||
<!-- <div
|
||||
class="my-12 p-4 bg-linear-to-r from-blue-50 to-indigo-50 rounded-2xl shadow-md text-center text-sm md:text-base text-gray-600"
|
||||
in:fade={{ delay: 1500, duration: 1000 }}
|
||||
>
|
||||
Thank you so much for playing! Feel free to email me directly with feedback:
|
||||
<a
|
||||
href="mailto:george@snail.city"
|
||||
class="font-semibold text-blue-600 hover:text-blue-800 underline"
|
||||
>george@snail.city</a
|
||||
>
|
||||
</div> -->
|
||||
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="inline-flex w-full flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
A project by George Powell & Silent Summit Co.
|
||||
</p>
|
||||
<!-- <p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
||||
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,105 +1,199 @@
|
||||
<script lang="ts">
|
||||
interface Guess {
|
||||
book: {
|
||||
id: string;
|
||||
name: string;
|
||||
testament: string;
|
||||
section: string;
|
||||
};
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
}
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
let { guesses, correctBookId }: { guesses: Guess[]; correctBookId: string } =
|
||||
$props();
|
||||
let {
|
||||
guesses,
|
||||
correctBookId,
|
||||
}: { guesses: Guess[]; correctBookId: string } = $props();
|
||||
|
||||
let hasGuesses = $derived(guesses.length > 0);
|
||||
let hasGuesses = $derived(guesses.length > 0);
|
||||
|
||||
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
|
||||
if (isCorrect) return "bg-green-500 border-green-600";
|
||||
if (isAdjacent) return "bg-yellow-500 border-yellow-600";
|
||||
return "bg-red-500 border-red-600";
|
||||
}
|
||||
|
||||
function getBoxContent(
|
||||
guess: Guess,
|
||||
column: "book" | "firstLetter" | "testament" | "section",
|
||||
): string {
|
||||
switch (column) {
|
||||
case "book":
|
||||
return guess.book.name;
|
||||
case "firstLetter":
|
||||
// Check if this is the special Epistles + "1" case
|
||||
const correctBook = bibleBooks.find(
|
||||
(b) => b.id === correctBookId,
|
||||
);
|
||||
const correctIsEpistlesWithNumber =
|
||||
(correctBook?.section === "Pauline Epistles" ||
|
||||
correctBook?.section === "General Epistles") &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(guess.book.section === "Pauline Epistles" ||
|
||||
guess.book.section === "General Epistles") &&
|
||||
guess.book.name[0] === "1";
|
||||
|
||||
if (
|
||||
correctIsEpistlesWithNumber &&
|
||||
guessIsEpistlesWithNumber &&
|
||||
guess.firstLetterMatch
|
||||
) {
|
||||
const words = [
|
||||
"Exactly",
|
||||
"Right",
|
||||
"Yes",
|
||||
"Naturally",
|
||||
"Of course",
|
||||
"Sure",
|
||||
];
|
||||
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
||||
}
|
||||
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
||||
case "testament":
|
||||
return (
|
||||
guess.book.testament.charAt(0).toUpperCase() +
|
||||
guess.book.testament.slice(1).toLowerCase()
|
||||
);
|
||||
case "section":
|
||||
return guess.book.section;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasGuesses}
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-x-auto fade-in">
|
||||
<table class="w-full">
|
||||
<thead class="fade-in">
|
||||
<tr class="bg-linear-to-r from-gray-50 to-gray-300">
|
||||
<th
|
||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
||||
>Book</th
|
||||
>
|
||||
<th
|
||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
||||
>Testament</th
|
||||
>
|
||||
<th
|
||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
||||
>Section</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each guesses as guess, index (guess.book.id)}
|
||||
<tr
|
||||
class="border-b border-gray-100 transition-colors {guess.book.id ===
|
||||
correctBookId
|
||||
? 'bg-green-200 animate-shine'
|
||||
: 'hover:bg-gray-50'} {index === 0 ? 'fade-in' : ''}"
|
||||
>
|
||||
<td
|
||||
class="p-3 sm:p-4 md:p-6 text-sm sm:text-base font-bold md:text-lg"
|
||||
>
|
||||
{guess.book.id === correctBookId ? "✅" : "❌"}
|
||||
{guess.book.name}
|
||||
</td>
|
||||
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg">
|
||||
{guess.testamentMatch ? "✅" : "❌"}
|
||||
{guess.book.testament.charAt(0).toUpperCase() +
|
||||
guess.book.testament.slice(1).toLowerCase()}
|
||||
</td>
|
||||
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg">
|
||||
{guess.sectionMatch ? "✅" : "❌"}
|
||||
{guess.adjacent ? "‼️ " : ""}{guess.book.section}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if !hasGuesses}
|
||||
<Container class="p-6 text-center">
|
||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800 dark:text-gray-100">
|
||||
Instructions
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed italic">
|
||||
Guess what book of the bible you think the verse is from. You will
|
||||
get clues to help you after each guess.
|
||||
</p>
|
||||
</Container>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<!-- Column Headers -->
|
||||
<div
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||
>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Testament
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Section
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
First Letter
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<!-- Testament Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.testamentMatch,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "testament")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Section Column -->
|
||||
<div
|
||||
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.sectionMatch,
|
||||
guess.adjacent,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "section")}
|
||||
{#if guess.adjacent}
|
||||
‼️
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- First Letter Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.firstLetterMatch,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "firstLetter")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Book Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
|
||||
guess.book.id === correctBookId,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
@keyframes flipIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: rotateX(-90deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotateX(0deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: rotateX(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shine {
|
||||
background: linear-gradient(110deg, #dcffe7 45%, #f1fff5 50%, #dcffe7 55%);
|
||||
background-size: 200% 100%;
|
||||
animation: shine 5s infinite;
|
||||
}
|
||||
.animate-flip-in {
|
||||
opacity: 0;
|
||||
transform: rotateX(-90deg);
|
||||
animation: flipIn 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-shine.fade-in {
|
||||
animation:
|
||||
fadeIn 0.5s ease-out,
|
||||
shine 5s infinite;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
246
src/lib/components/Imposter.svelte
Normal file
246
src/lib/components/Imposter.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface ImposterData {
|
||||
verses: string[];
|
||||
refs: string[];
|
||||
imposterIndex: number;
|
||||
}
|
||||
|
||||
let data: ImposterData | null = $state(null);
|
||||
let clicked: boolean[] = $state([]);
|
||||
let gameOver = $state(false);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
async function loadGame() {
|
||||
try {
|
||||
const res = await fetch("/api/imposter");
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
data = (await res.json()) as ImposterData;
|
||||
clicked = new Array(data.verses.length).fill(false);
|
||||
gameOver = false;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unknown error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(index: number) {
|
||||
if (gameOver || !data || clicked[index]) return;
|
||||
clicked[index] = true;
|
||||
if (index !== data.imposterIndex) {
|
||||
clicked[data.imposterIndex] = true;
|
||||
}
|
||||
gameOver = true;
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
loading = true;
|
||||
error = null;
|
||||
data = null;
|
||||
loadGame();
|
||||
}
|
||||
|
||||
onMount(loadGame);
|
||||
|
||||
function formatVerse(verse: string): string {
|
||||
let formatted = verse;
|
||||
|
||||
// Handle unbalanced opening/closing punctuation
|
||||
const pairs: [string, string][] = [
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
["\u201C", "\u201D"], // \u201C
|
||||
["\u2018", "\u2019"], // \u2018
|
||||
];
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||
formatted += "..." + close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||
formatted = open + "..." + formatted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-z]/.test(formatted)) {
|
||||
formatted = "..." + formatted;
|
||||
}
|
||||
// Replace trailing punctuation with ellipsis
|
||||
// Preserve closing quotes/brackets that may have been added
|
||||
formatted = formatted.replace(
|
||||
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||
"...$1",
|
||||
);
|
||||
return formatted;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="imposter-game">
|
||||
{#if loading}
|
||||
<p class="loading">Loading verses...</p>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Error: {error}</p>
|
||||
<button onclick={newGame}>Retry</button>
|
||||
</div>
|
||||
{:else if data}
|
||||
<!-- <div class="instructions">
|
||||
<p>Click the verse that doesn't belong (from a different book).</p>
|
||||
</div> -->
|
||||
<div class="verses">
|
||||
{#each data.verses as verse, i}
|
||||
<div class="verse-item">
|
||||
<button
|
||||
class="verse-button"
|
||||
class:clicked={clicked[i]}
|
||||
class:correct={clicked[i] && i === data.imposterIndex}
|
||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||
onclick={() => handleClick(i)}
|
||||
disabled={gameOver}
|
||||
>
|
||||
{formatVerse(verse)}
|
||||
</button>
|
||||
{#if gameOver}
|
||||
<div class="ref">{data.refs[i]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if gameOver}
|
||||
<div class="result">
|
||||
<button onclick={newGame}>New Game</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.imposter-game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*.instructions {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}*/
|
||||
|
||||
.verses {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.verse-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.verse-button {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
border: 3px solid #ddd;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 100px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||
border-color: #007bff;
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
.verse-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.verse-button.clicked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.correct {
|
||||
background: #d4edda !important;
|
||||
border-color: #28a745 !important;
|
||||
color: #155724;
|
||||
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.wrong {
|
||||
background: #f8d7da !important;
|
||||
border-color: #dc3545 !important;
|
||||
color: #721c24;
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.ref {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.verse-button.correct ~ .ref {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verse-button.wrong ~ .ref {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result button,
|
||||
.error button {
|
||||
padding: 0.75rem 2rem;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.result button:hover,
|
||||
.error button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +1,309 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
||||
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
|
||||
let {
|
||||
searchQuery = $bindable(""),
|
||||
guessedIds,
|
||||
submitGuess,
|
||||
guessCount = 0,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
guessedIds: SvelteSet<string>;
|
||||
submitGuess: (id: string) => void;
|
||||
guessCount: number;
|
||||
} = $props();
|
||||
|
||||
let filteredBooks = $derived(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
);
|
||||
type DisplayMode = "simple" | "testament" | "sections";
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
||||
submitGuess(filteredBooks[0].id);
|
||||
}
|
||||
}
|
||||
const displayMode = $derived<DisplayMode>(
|
||||
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
|
||||
);
|
||||
|
||||
const filteredBooks = $derived(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
type SimpleGroup = { books: BibleBook[] };
|
||||
|
||||
type TestamentGroup = {
|
||||
testament: Testament;
|
||||
label: string;
|
||||
books: BibleBook[];
|
||||
};
|
||||
|
||||
type SectionGroup = {
|
||||
testament: Testament;
|
||||
testamentLabel: string;
|
||||
showTestamentHeader: boolean;
|
||||
section: BibleSection;
|
||||
books: BibleBook[];
|
||||
};
|
||||
|
||||
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||
const sorted = [...filteredBooks].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
return { books: sorted };
|
||||
});
|
||||
|
||||
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||
const old = filteredBooks
|
||||
.filter((b) => b.testament === "old")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const newT = filteredBooks
|
||||
.filter((b) => b.testament === "new")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const groups: TestamentGroup[] = [];
|
||||
if (old.length > 0) {
|
||||
groups.push({ testament: "old", label: "Old Testament", books: old });
|
||||
}
|
||||
if (newT.length > 0) {
|
||||
groups.push({ testament: "new", label: "New Testament", books: newT });
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||
const seenKeys: Record<string, true> = {};
|
||||
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
|
||||
|
||||
for (const book of bibleBooks) {
|
||||
const key = `${book.testament}:${book.section}`;
|
||||
if (!seenKeys[key]) {
|
||||
seenKeys[key] = true;
|
||||
orderedPairs.push({ testament: book.testament, section: book.section });
|
||||
}
|
||||
}
|
||||
|
||||
const groups: SectionGroup[] = [];
|
||||
let lastTestament: Testament | null = null;
|
||||
|
||||
for (const pair of orderedPairs) {
|
||||
const books = filteredBooks.filter(
|
||||
(b) => b.testament === pair.testament && b.section === pair.section
|
||||
);
|
||||
if (books.length === 0) continue;
|
||||
|
||||
const showTestamentHeader = pair.testament !== lastTestament;
|
||||
lastTestament = pair.testament;
|
||||
|
||||
groups.push({
|
||||
testament: pair.testament,
|
||||
testamentLabel:
|
||||
pair.testament === "old" ? "Old Testament" : "New Testament",
|
||||
showTestamentHeader,
|
||||
section: pair.section,
|
||||
books,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// First book in display order for Enter key submission
|
||||
const firstBookId = $derived.by<string | null>(() => {
|
||||
if (filteredBooks.length === 0) return null;
|
||||
if (displayMode === "simple") {
|
||||
return simpleGroup.books[0]?.id ?? null;
|
||||
}
|
||||
if (displayMode === "testament") {
|
||||
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||
}
|
||||
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && firstBookId) {
|
||||
submitGuess(firstBookId);
|
||||
}
|
||||
}
|
||||
|
||||
const showBanner = $derived(guessCount >= 3);
|
||||
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||
</script>
|
||||
|
||||
<div class="mb-12">
|
||||
<input
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||
class="w-full p-4 sm:p-6 border-2 border-gray-200 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all shadow-lg"
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
{#if searchQuery && filteredBooks.length > 0}
|
||||
<ul
|
||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-2xl shadow-lg"
|
||||
>
|
||||
{#each filteredBooks as book (book.id)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(
|
||||
book.id,
|
||||
)
|
||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-500'
|
||||
: ''}">{book.name}</span
|
||||
>
|
||||
<span class="ml-auto text-sm opacity-75"
|
||||
>({book.testament.toUpperCase()})</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
||||
{/if}
|
||||
{#if showBanner}
|
||||
<p
|
||||
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if bannerIsIndigo}
|
||||
Testament & section groups now visible
|
||||
{:else}
|
||||
Old & New Testament groups now visible
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
onkeydown={handleKeydown}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchQuery && filteredBooks.length > 0}
|
||||
<ul
|
||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
{#if displayMode === "simple"}
|
||||
{#each simpleGroup.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold dark:text-gray-100 {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{:else if displayMode === "testament"}
|
||||
{#each testamentGroups as group (group.testament)}
|
||||
<li role="presentation">
|
||||
<div
|
||||
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||
<li role="presentation">
|
||||
{#if group.showTestamentHeader}
|
||||
<div
|
||||
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{group.testamentLabel}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{group.section}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
31
src/lib/components/StreakCounter.svelte
Normal file
31
src/lib/components/StreakCounter.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
streak,
|
||||
streakPercentile = null,
|
||||
}: {
|
||||
streak: number;
|
||||
streakPercentile?: number | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm flex-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="text-5xl font-triodion font-black text-orange-500 leading-none tabular-nums"
|
||||
>
|
||||
{streak}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs uppercase justify-center tracking-widest text-gray-500 dark:text-gray-200 font-triodion font-bold py-2 leading-tight"
|
||||
>
|
||||
day{streak === 1 ? "" : "s"} in a row
|
||||
</p>
|
||||
{#if streakPercentile !== null && streakPercentile <= 50}
|
||||
<p
|
||||
class="text-xs text-black dark:text-gray-200 w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 dark:border-t-stone-600 pt-2"
|
||||
>
|
||||
Top {streakPercentile}%
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,25 +1,87 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import { browser } from "$app/environment";
|
||||
import { fade } from "svelte/transition";
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
let { data, isWon }: { data: PageData; isWon: boolean } = $props();
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let displayReference = $derived(
|
||||
dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
||||
);
|
||||
let displayVerseText = $derived(
|
||||
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||
);
|
||||
let {
|
||||
data,
|
||||
isWon,
|
||||
blurChapter = false,
|
||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let displayReference = $derived(
|
||||
blurChapter
|
||||
? dailyVerse.reference
|
||||
.replace(/^Psalms /, "Psalm ")
|
||||
.replace(/\s(\d+):/, " ?:")
|
||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||
);
|
||||
let displayVerseText = $derived(
|
||||
dailyVerse.verseText
|
||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||
.replace(/[,:;-—]$/, "..."),
|
||||
);
|
||||
|
||||
let showReference = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
// Delay showing reference until GuessesTable animation completes
|
||||
$effect(() => {
|
||||
if (!isWon) {
|
||||
showReference = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user already won today (page reload case)
|
||||
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
const alreadyWonToday =
|
||||
browser && localStorage.getItem(winTrackedKey) === "true";
|
||||
|
||||
if (alreadyWonToday) {
|
||||
// User already won and is refreshing - show immediately
|
||||
showReference = true;
|
||||
} else {
|
||||
// User just won this session - delay for animation
|
||||
const animationDelay = 1800;
|
||||
const timeoutId = setTimeout(() => {
|
||||
showReference = true;
|
||||
}, animationDelay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
function copyVerse() {
|
||||
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||
copied = true;
|
||||
(window as any).rybbit?.event("Copy Verse");
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-gray-50 rounded-2xl shadow-xl p-8 sm:p-12 mb-4 sm:mb-12 w-full">
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
{#if isWon}
|
||||
<p class="text-center text-lg! big-text text-green-600! font-bold mt-8">
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Container
|
||||
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||
>
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||
style="max-height: {showReference ? '200px' : '0px'};"
|
||||
>
|
||||
{#if showReference}
|
||||
<p
|
||||
transition:fade={{ duration: 400 }}
|
||||
class="text-center text-lg! big-text text-green-600! dark:text-green-400! font-bold mt-8 bg-white/70 dark:bg-black/50 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,196 +1,601 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { getBookById, toOrdinal, getNextGradeMessage } from "$lib/utils/game";
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||
import {
|
||||
getVerseSnippet,
|
||||
shareResult,
|
||||
copyToClipboard as clipboardCopy,
|
||||
} from "$lib/utils/share";
|
||||
import Container from "./Container.svelte";
|
||||
import CountdownTimer from "./CountdownTimer.svelte";
|
||||
import StreakCounter from "./StreakCounter.svelte";
|
||||
import ChapterGuess from "./ChapterGuess.svelte";
|
||||
|
||||
interface StatsData {
|
||||
solveRank: number;
|
||||
guessRank: number;
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
}
|
||||
interface StatsData {
|
||||
solveRank: number;
|
||||
guessRank: number;
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
tiedCount: number;
|
||||
percentile: number;
|
||||
}
|
||||
|
||||
interface WeightedMessage {
|
||||
text: string;
|
||||
weight: number;
|
||||
}
|
||||
interface WeightedMessage {
|
||||
text: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
let {
|
||||
grade,
|
||||
statsData,
|
||||
correctBookId,
|
||||
handleShare,
|
||||
copyToClipboard,
|
||||
copied = $bindable(false),
|
||||
statsSubmitted,
|
||||
guessCount,
|
||||
} = $props();
|
||||
let {
|
||||
statsData,
|
||||
correctBookId,
|
||||
handleShare,
|
||||
copyToClipboard,
|
||||
copied = $bindable(false),
|
||||
statsSubmitted,
|
||||
guessCount,
|
||||
reference,
|
||||
onChapterGuessCompleted,
|
||||
shareText,
|
||||
verseText,
|
||||
streak = 0,
|
||||
streakPercentile = null,
|
||||
}: {
|
||||
statsData: StatsData | null;
|
||||
correctBookId: string;
|
||||
handleShare: () => void;
|
||||
copyToClipboard: () => void;
|
||||
copied: boolean;
|
||||
statsSubmitted: boolean;
|
||||
guessCount: number;
|
||||
reference: string;
|
||||
onChapterGuessCompleted: () => void;
|
||||
shareText: string;
|
||||
verseText: string;
|
||||
streak?: number;
|
||||
streakPercentile?: number | null;
|
||||
} = $props();
|
||||
|
||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||
let hasWebShare = $derived(
|
||||
typeof navigator !== "undefined" && "share" in navigator
|
||||
);
|
||||
let copySuccess = $state(false);
|
||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||
let hasWebShare = $derived(
|
||||
typeof navigator !== "undefined" && "share" in navigator,
|
||||
);
|
||||
let copySuccess = $state(false);
|
||||
let bubbleCopied = $state(false);
|
||||
let copyTracked = $state(false);
|
||||
let showSnippetOption = $state(false);
|
||||
let includeSnippet = $state(false);
|
||||
|
||||
// List of congratulations messages with weights
|
||||
const congratulationsMessages: WeightedMessage[] = [
|
||||
{ text: "Congratulations!", weight: 10 },
|
||||
{ text: "You got it!", weight: 1000 },
|
||||
{ text: "Yup,", weight: 100 },
|
||||
{ text: "Very nice!", weight: 1 },
|
||||
];
|
||||
let effectiveShareText = $derived(
|
||||
includeSnippet
|
||||
? (() => {
|
||||
const snippet = getVerseSnippet(verseText);
|
||||
const lines = shareText.split("\n");
|
||||
return [
|
||||
...lines.slice(0, -1),
|
||||
snippet,
|
||||
lines[lines.length - 1],
|
||||
].join("\n");
|
||||
})()
|
||||
: shareText,
|
||||
);
|
||||
|
||||
// Function to select a random message based on weights
|
||||
function getRandomCongratulationsMessage(): string {
|
||||
// Special case for first try success
|
||||
if (guessCount === 1) {
|
||||
const n = Math.random();
|
||||
if (n < 0.99) {
|
||||
return "🌟 First try! 🌟";
|
||||
} else {
|
||||
return "🗣️ Axios! 🗣️";
|
||||
}
|
||||
}
|
||||
// List of congratulations messages with weights
|
||||
const congratulationsMessages: WeightedMessage[] = [
|
||||
{ text: "Congratulations!", weight: 10 },
|
||||
{ text: "You got it!", weight: 1000 },
|
||||
{ text: "Yup.", weight: 100 },
|
||||
{ text: "Very nice!", weight: 1 },
|
||||
];
|
||||
|
||||
const totalWeight = congratulationsMessages.reduce(
|
||||
(sum, msg) => sum + msg.weight,
|
||||
0
|
||||
);
|
||||
let random = Math.random() * totalWeight;
|
||||
// Function to select a random message based on weights
|
||||
function getRandomCongratulationsMessage(): string {
|
||||
// Special case for first try success
|
||||
if (guessCount === 1) {
|
||||
const n = Math.random();
|
||||
if (n < 0.99) {
|
||||
return "First try!";
|
||||
} else {
|
||||
return "Axios!";
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of congratulationsMessages) {
|
||||
random -= message.weight;
|
||||
if (random <= 0) {
|
||||
return message.text;
|
||||
}
|
||||
}
|
||||
const totalWeight = congratulationsMessages.reduce(
|
||||
(sum, msg) => sum + msg.weight,
|
||||
0,
|
||||
);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
// Fallback to first message if something goes wrong
|
||||
return congratulationsMessages[0].text;
|
||||
}
|
||||
for (const message of congratulationsMessages) {
|
||||
random -= message.weight;
|
||||
if (random <= 0) {
|
||||
return message.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the congratulations message
|
||||
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
|
||||
// Fallback to first message if something goes wrong
|
||||
return congratulationsMessages[0].text;
|
||||
}
|
||||
|
||||
// Generate the congratulations message
|
||||
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="p-8 sm:p-12 w-full bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center fade-in"
|
||||
>
|
||||
<!-- <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg">
|
||||
{congratulationsMessage}
|
||||
</h2> -->
|
||||
<p class="text-xl sm:text-3xl md:text-4xl">
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black text-xl sm:text-2xl md:text-3xl">{bookName}</span>.
|
||||
</p>
|
||||
<p
|
||||
class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block"
|
||||
>
|
||||
Your grade: {grade}
|
||||
</p>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Container
|
||||
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||
>{bookName}</span
|
||||
>.
|
||||
</p>
|
||||
<p class="text-lg sm:text-xl md:text-2xl">
|
||||
You guessed correctly after {guessCount}
|
||||
{guessCount === 1 ? "guess" : "guesses"}.
|
||||
</p>
|
||||
<!-- {#if streak >= 7}
|
||||
<p
|
||||
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||
>
|
||||
Thank you for making BIBDLE part of your daily routine!
|
||||
</p>
|
||||
{/if} -->
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{#if hasWebShare}
|
||||
<button
|
||||
onclick={handleShare}
|
||||
data-umami-event="Share"
|
||||
class="mt-4 text-2xl font-bold p-2 bg-white/20 hover:bg-white/30 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
|
||||
>
|
||||
📤 Share
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
copyToClipboard();
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
copySuccess = false;
|
||||
}, 3000);
|
||||
}}
|
||||
data-umami-event="Copy to Clipboard"
|
||||
class={`mt-4 text-2xl font-bold p-2 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
|
||||
copySuccess
|
||||
? "bg-green-400/50 hover:bg-green-500/60"
|
||||
: "bg-white/20 hover:bg-white/30"
|
||||
}`}
|
||||
>
|
||||
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleShare}
|
||||
data-umami-event="Share"
|
||||
class={`mt-4 text-2xl font-bold p-2 ${
|
||||
copied
|
||||
? "bg-green-400/50 hover:bg-green-500/60"
|
||||
: "bg-white/20 hover:bg-white/30"
|
||||
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
||||
>
|
||||
{copied ? "Copied to clipboard!" : "📤 Share"}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- S++ Bonus Challenge for first try -->
|
||||
{#if guessCount === 1}
|
||||
<ChapterGuess
|
||||
{reference}
|
||||
bookId={correctBookId}
|
||||
onCompleted={onChapterGuessCompleted}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<p class="pt-6 big-text text-gray-100!">
|
||||
{getNextGradeMessage(guessCount)}
|
||||
</p>
|
||||
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||
<div class="flex-2 min-w-0 flex flex-col">
|
||||
<CountdownTimer />
|
||||
</div>
|
||||
{#if streak > 0}
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<StreakCounter {streak} {streakPercentile} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Statistics Display -->
|
||||
{#if statsData}
|
||||
<div class="mt-6" in:fade={{ delay: 800 }}>
|
||||
<div class="grid grid-cols-3 gap-4 gap-x-8 text-center">
|
||||
<!-- Solve Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div class="text-3xl sm:text-4xl font-black">
|
||||
#{statsData.solveRank}
|
||||
</div>
|
||||
<div class="text-xs sm:text-sm opacity-90 mt-1">
|
||||
You were the {toOrdinal(statsData.solveRank)} person to solve today
|
||||
</div>
|
||||
</div>
|
||||
<!-- Statistics Display -->
|
||||
{#if statsData}
|
||||
<Container
|
||||
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||
in:fade={{ delay: 800 }}
|
||||
>
|
||||
<!-- Solve Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
#{statsData.solveRank}
|
||||
</div>
|
||||
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||
You were the {toOrdinal(statsData.solveRank)} person to solve
|
||||
today
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guess Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div class="text-3xl sm:text-4xl font-black">
|
||||
{Math.round(
|
||||
((statsData.totalSolves - statsData.guessRank + 1) /
|
||||
statsData.totalSolves) *
|
||||
100
|
||||
)}%
|
||||
</div>
|
||||
<div class="text-xs sm:text-sm opacity-90 mt-1">
|
||||
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
|
||||
total solves
|
||||
</div>
|
||||
</div>
|
||||
<!-- Guess Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
{toOrdinal(statsData.guessRank)}
|
||||
</div>
|
||||
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
|
||||
{statsData.totalSolves === 1
|
||||
? "solve"
|
||||
: "solves"}{statsData.tiedCount > 0
|
||||
? `, tied with ${statsData.tiedCount} ${statsData.tiedCount === 1 ? "other" : "others"}`
|
||||
: ""}.<br />
|
||||
{#if statsData.percentile <= 25}
|
||||
<span class="font-bold">
|
||||
(Top {statsData.percentile}%)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Average Column -->
|
||||
<div class="flex flex-col">
|
||||
<div class="text-3xl sm:text-4xl font-black">
|
||||
{statsData.averageGuesses}
|
||||
</div>
|
||||
<div class="text-xs sm:text-sm opacity-90 mt-1">
|
||||
People guessed correctly after {statsData.averageGuesses}
|
||||
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on average
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !statsSubmitted}
|
||||
<div class="mt-6 text-sm opacity-80">Submitting stats...</div>
|
||||
{/if}
|
||||
<!-- Average Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
{statsData.averageGuesses}
|
||||
</div>
|
||||
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||
People solved after {statsData.averageGuesses}
|
||||
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on
|
||||
average
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{:else if !statsSubmitted}
|
||||
<Container
|
||||
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||
>
|
||||
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||
<div class="big-text font-black! text-center">Share your result</div>
|
||||
<div class="chat-window">
|
||||
<!-- Received bubble: primary action (share / copy) -->
|
||||
<div class="bubble-wrapper received-wrapper">
|
||||
<button
|
||||
class="bubble bubble-received"
|
||||
class:success={copySuccess}
|
||||
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
||||
data-umami-event={hasWebShare
|
||||
? "Share"
|
||||
: "Copy to Clipboard"}
|
||||
onclick={() => {
|
||||
if (hasWebShare) {
|
||||
(window as any).rybbit?.event("Share");
|
||||
shareResult(effectiveShareText);
|
||||
} else {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event(
|
||||
"Copy to Clipboard",
|
||||
);
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
copySuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if hasWebShare}
|
||||
📤 Tap here to share
|
||||
{:else if copySuccess}
|
||||
✅ Copied!
|
||||
{:else}
|
||||
📋 Copy to clipboard
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sent bubble: share text preview -->
|
||||
<div class="bubble-wrapper">
|
||||
<button
|
||||
class="bubble bubble-sent"
|
||||
aria-label="Copy to clipboard"
|
||||
data-umami-event="Copy to Clipboard"
|
||||
onclick={() => {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event("Copy to Clipboard");
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
showSnippetOption = true;
|
||||
bubbleCopied = true;
|
||||
setTimeout(() => {
|
||||
bubbleCopied = false;
|
||||
}, 2000);
|
||||
}}>{effectiveShareText}</button
|
||||
>
|
||||
{#if hasWebShare}
|
||||
<span class="copy-hint"
|
||||
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="copy-hint"
|
||||
>{bubbleCopied ? "copied!" : ""}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSnippetOption}
|
||||
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
||||
<span class="snippet-label">Show verse snippet in share?</span>
|
||||
<button
|
||||
class="snippet-toggle"
|
||||
class:on={includeSnippet}
|
||||
onclick={() => (includeSnippet = !includeSnippet)}
|
||||
aria-pressed={includeSnippet}
|
||||
aria-label="Show snippet in share"
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
:global(.fade-in) {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* ── Share card ── */
|
||||
.share-card {
|
||||
background: oklch(94% 0.028 298.626);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.share-card {
|
||||
background: oklch(22% 0.025 298.626);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.share-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Chat window ── */
|
||||
.chat-window {
|
||||
--sent-color: #0b93f6;
|
||||
--received-color: #3a3a3c;
|
||||
--bg: oklch(94% 0.028 298.626);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.5rem 0;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-window {
|
||||
--bg: oklch(22% 0.025 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bubble wrappers ── */
|
||||
.bubble-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.received-wrapper {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Shared bubble base ── */
|
||||
.bubble {
|
||||
position: relative;
|
||||
max-width: 255px;
|
||||
margin-bottom: 0;
|
||||
padding: 10px 20px;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
border-radius: 25px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 80ms ease,
|
||||
transform 80ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Sent bubble (share text preview) ── */
|
||||
.bubble-sent {
|
||||
color: white;
|
||||
background: var(--sent-color);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.bubble-sent:hover {
|
||||
background-color: #2ea8ff;
|
||||
transform: rotate(-2deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.bubble-sent:hover::before {
|
||||
background-color: #2ea8ff;
|
||||
}
|
||||
|
||||
.bubble-sent:active {
|
||||
background-color: #0878d4;
|
||||
transform: rotate(-2deg) scale(0.97);
|
||||
}
|
||||
|
||||
.bubble-sent:active::before {
|
||||
background-color: #0878d4;
|
||||
}
|
||||
|
||||
/* Sent tail: bottom-right */
|
||||
.bubble-sent::before,
|
||||
.bubble-sent::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 25px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bubble-sent::before {
|
||||
width: 20px;
|
||||
right: -7px;
|
||||
background-color: var(--sent-color);
|
||||
border-bottom-left-radius: 16px 14px;
|
||||
}
|
||||
|
||||
.bubble-sent::after {
|
||||
width: 26px;
|
||||
right: -26px;
|
||||
border-bottom-left-radius: 10px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Received bubble (action button) ── */
|
||||
.bubble-received {
|
||||
color: #f5f5f7;
|
||||
background: var(--received-color);
|
||||
transform: rotate(2deg);
|
||||
padding: 14px 24px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
min-width: 14rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bubble-received:hover {
|
||||
background-color: #4a4a4e;
|
||||
transform: rotate(2deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.bubble-received:hover::before {
|
||||
background-color: #4a4a4e;
|
||||
}
|
||||
|
||||
.bubble-received:active {
|
||||
background-color: #2a2a2c;
|
||||
transform: rotate(2deg) scale(0.97);
|
||||
}
|
||||
|
||||
.bubble-received:active::before {
|
||||
background-color: #2a2a2c;
|
||||
}
|
||||
|
||||
.bubble-received.success {
|
||||
background: #c7f7d4;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
/* Received tail: bottom-left (mirror of sent) */
|
||||
.bubble-received::before,
|
||||
.bubble-received::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 25px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bubble-received::before {
|
||||
width: 20px;
|
||||
left: -7px;
|
||||
background-color: var(--received-color);
|
||||
border-bottom-right-radius: 16px 14px;
|
||||
}
|
||||
|
||||
.bubble-received::after {
|
||||
width: 26px;
|
||||
left: -26px;
|
||||
border-bottom-right-radius: 10px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.bubble-received.success::before {
|
||||
background-color: #c7f7d4;
|
||||
}
|
||||
|
||||
/* ── Copy hints ── */
|
||||
.copy-hint {
|
||||
font-size: 0.68rem;
|
||||
color: #444;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
padding-right: 32px;
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: right center;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.copy-hint {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Snippet toggle row ── */
|
||||
.snippet-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.snippet-label {
|
||||
font-size: 0.72rem;
|
||||
color: #666;
|
||||
letter-spacing: 0.01em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.snippet-label {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.snippet-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #ccc;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snippet-toggle.on {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.snippet-toggle.on .toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
export * from './utils/game';
|
||||
export { default as VerseDisplay } from './components/VerseDisplay.svelte';
|
||||
export { default as SearchInput } from './components/SearchInput.svelte';
|
||||
export { default as GuessesTable } from './components/GuessesTable.svelte';
|
||||
export { default as WinScreen } from './components/WinScreen.svelte';
|
||||
export { default as Feedback } from './components/Feedback.svelte';
|
||||
|
||||
144
src/lib/server/apple-auth.ts
Normal file
144
src/lib/server/apple-auth.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
|
||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||
|
||||
export function getAppleAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.APPLE_ID!,
|
||||
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/apple/callback`,
|
||||
response_type: 'code',
|
||||
response_mode: 'form_post',
|
||||
scope: 'name email',
|
||||
state
|
||||
});
|
||||
return `${APPLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function generateAppleClientSecret(): Promise<string> {
|
||||
const header = { alg: 'ES256', kid: Bun.env.APPLE_KEY_ID! };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: Bun.env.APPLE_TEAM_ID!,
|
||||
iat: now,
|
||||
exp: now + 86400 * 180,
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: Bun.env.APPLE_ID!
|
||||
};
|
||||
|
||||
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
||||
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Import PEM private key
|
||||
const pemBody = Bun.env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBuffer,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const signature = new Uint8Array(signatureBuffer);
|
||||
|
||||
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||
const encodedSignature = encodeBase64url(rawSignature);
|
||||
|
||||
return `${signingInput}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
|
||||
*/
|
||||
function derToRaw(der: Uint8Array): Uint8Array {
|
||||
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
|
||||
let offset = 2; // skip 0x30 and total length
|
||||
|
||||
// Read r
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const rLen = der[offset];
|
||||
offset++;
|
||||
let r = der.slice(offset, offset + rLen);
|
||||
offset += rLen;
|
||||
|
||||
// Read s
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const sLen = der[offset];
|
||||
offset++;
|
||||
let s = der.slice(offset, offset + sLen);
|
||||
|
||||
// Remove leading zero padding (DER uses it for positive sign)
|
||||
if (r.length === 33 && r[0] === 0) r = r.slice(1);
|
||||
if (s.length === 33 && s[0] === 0) s = s.slice(1);
|
||||
|
||||
// Pad to 32 bytes each
|
||||
const raw = new Uint8Array(64);
|
||||
raw.set(r, 32 - r.length);
|
||||
raw.set(s, 64 - s.length);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function exchangeAppleCode(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
id_token: string;
|
||||
}> {
|
||||
const clientSecret = await generateAppleClientSecret();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.APPLE_ID!,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
});
|
||||
|
||||
const response = await fetch(APPLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Apple token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Apple's id_token JWT payload without signature verification.
|
||||
* Safe because the token is received directly from Apple's token endpoint over TLS.
|
||||
*/
|
||||
export function decodeAppleIdToken(idToken: string): {
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: string;
|
||||
is_private_email?: string;
|
||||
} {
|
||||
const [, payloadB64] = idToken.split('.');
|
||||
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return payload;
|
||||
}
|
||||
116
src/lib/server/auth.test.ts
Normal file
116
src/lib/server/auth.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { testDb as db } from '$lib/server/db/test';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
|
||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export const sessionCookieName = 'auth-session';
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||
const token = encodeBase64url(bytes);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function createSession(token: string, userId: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const session: table.Session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
|
||||
};
|
||||
await db.insert(table.session).values(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function validateSessionToken(token: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
user: { id: table.user.id, email: table.user.email },
|
||||
session: table.session
|
||||
})
|
||||
.from(table.session)
|
||||
.innerJoin(table.user, eq(table.session.userId, table.user.id))
|
||||
.where(eq(table.session.id, sessionId));
|
||||
|
||||
if (!result) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
const { session, user } = result;
|
||||
|
||||
const sessionExpired = Date.now() >= session.expiresAt.getTime();
|
||||
if (sessionExpired) {
|
||||
await db.delete(table.session).where(eq(table.session.id, session.id));
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
|
||||
if (renewSession) {
|
||||
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
|
||||
await db
|
||||
.update(table.session)
|
||||
.set({ expiresAt: session.expiresAt })
|
||||
.where(eq(table.session.id, session.id));
|
||||
}
|
||||
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
|
||||
|
||||
export async function invalidateSession(sessionId: string) {
|
||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
||||
event.cookies.set(sessionCookieName, token, {
|
||||
expires: expiresAt,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
event.cookies.delete(sessionCookieName, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 4,
|
||||
timeCost: 3
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
|
||||
const user: table.User = {
|
||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
};
|
||||
await db.insert(table.user).values(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||
return user || null;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export async function validateSessionToken(token: string) {
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
user: { id: table.user.id, username: table.user.username },
|
||||
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId },
|
||||
session: table.session
|
||||
})
|
||||
.from(table.session)
|
||||
@@ -79,3 +79,83 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 4,
|
||||
timeCost: 3
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
|
||||
const user: table.User = {
|
||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
};
|
||||
await db.insert(table.user).values(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function getUserByAppleId(appleId: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||
if (!anonymousId || anonymousId === userId) return;
|
||||
|
||||
try {
|
||||
const { dailyCompletions } = await import('$lib/server/db/schema');
|
||||
|
||||
const anonCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const userDates = new Set(userCompletions.map((c) => c.date));
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const completion of anonCompletions) {
|
||||
if (!userDates.has(completion.date)) {
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: userId })
|
||||
.where(eq(dailyCompletions.id, completion.id));
|
||||
migrated++;
|
||||
} else {
|
||||
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id));
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||
} catch (error) {
|
||||
console.error('Error migrating anonymous stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/lib/server/daily-verse.ts
Normal file
33
src/lib/server/daily-verse.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
|
||||
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
throw new Error('Invalid date format');
|
||||
}
|
||||
|
||||
// If there's an existing verse for this date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse for this date
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: Bun.randomUUIDv7(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
if (!Bun.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = new Database(env.DATABASE_URL);
|
||||
const client = new Database(Bun.env.DATABASE_URL);
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
||||
export const user = sqliteTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
firstName: text('first_name'),
|
||||
lastName: text('last_name'),
|
||||
email: text('email').unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
appleId: text('apple_id').unique(),
|
||||
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||
});
|
||||
|
||||
export const session = sqliteTable('session', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -30,11 +36,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
||||
anonymousId: text('anonymous_id').notNull(),
|
||||
date: text('date').notNull(),
|
||||
guessCount: integer('guess_count').notNull(),
|
||||
guesses: text('guesses'), // nullable; only stored for logged-in users
|
||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => ({
|
||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
||||
dateIndex: index('date_idx').on(table.date),
|
||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
||||
}));
|
||||
}, (table) => [
|
||||
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||
index('date_idx').on(table.date),
|
||||
index('date_guess_idx').on(table.date, table.guessCount),
|
||||
// Ensures schema matches the database migration and prevents duplicate submissions
|
||||
unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
|
||||
]);
|
||||
|
||||
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||
|
||||
9
src/lib/server/db/test.ts
Normal file
9
src/lib/server/db/test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as schema from './schema';
|
||||
|
||||
if (!Bun.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
|
||||
|
||||
const testClient = new Database(Bun.env.TEST_DATABASE_URL);
|
||||
|
||||
export const testDb = drizzle(testClient, { schema });
|
||||
@@ -353,6 +353,54 @@ export function getRandomGreekVerses(count: number = 3): {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random set of verses from a specific book
|
||||
* Returns `count` consecutive verses by default
|
||||
*/
|
||||
export function getRandomVersesFromBook(
|
||||
bookNumber: number,
|
||||
count: number = 1
|
||||
): {
|
||||
bookId: string;
|
||||
bookName: string;
|
||||
chapter: number;
|
||||
startVerse: number;
|
||||
endVerse: number;
|
||||
verses: string[];
|
||||
} | null {
|
||||
const book = getBookByNumber(bookNumber);
|
||||
if (!book) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try up to 10 times to find a valid passage
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const chapterNumber = getRandomChapterNumber(bookNumber);
|
||||
const verseCount = getVerseCount(bookNumber, chapterNumber);
|
||||
|
||||
// Skip chapters that don't have enough verses
|
||||
if (verseCount < count) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startVerse = getRandomStartVerse(bookNumber, chapterNumber, count);
|
||||
const verses = extractVerses(bookNumber, chapterNumber, startVerse, count);
|
||||
|
||||
if (verses.length === count) {
|
||||
return {
|
||||
bookId: book.id,
|
||||
bookName: book.name,
|
||||
chapter: chapterNumber,
|
||||
startVerse,
|
||||
endVerse: startVerse + count - 1,
|
||||
verses
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a reference string from verse data
|
||||
*/
|
||||
|
||||
186
src/lib/stores/game-persistence.svelte.ts
Normal file
186
src/lib/stores/game-persistence.svelte.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
||||
|
||||
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
|
||||
// Used to attribute stats to a player who hasn't signed in.
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
const key = "bibdle-anonymous-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// Reactive store that keeps in-memory game state in sync with localStorage.
|
||||
// Accepts getter functions (rather than plain values) so Svelte's reactivity
|
||||
// system can track dependencies and re-run effects when they change.
|
||||
type AuthUser = {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export function createGamePersistence(
|
||||
getDate: () => string,
|
||||
getReference: () => string,
|
||||
getCorrectBookId: () => string,
|
||||
getUser: () => AuthUser | null | undefined,
|
||||
) {
|
||||
let guesses = $state<Guess[]>([]);
|
||||
let anonymousId = $state("");
|
||||
let statsSubmitted = $state(false);
|
||||
let chapterGuessCompleted = $state(false);
|
||||
let chapterCorrect = $state(false);
|
||||
|
||||
// On mount (and if the user logs in/out), resolve the player's identity and
|
||||
// restore per-day flags from localStorage.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const user = getUser();
|
||||
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||
if (user) {
|
||||
anonymousId = user.id;
|
||||
} else {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
}
|
||||
|
||||
// Tell analytics which player this is so events are grouped correctly.
|
||||
if ((window as any).umami) {
|
||||
(window as any).umami.identify(anonymousId);
|
||||
}
|
||||
if (user) {
|
||||
const nameParts = [user.firstName, user.lastName].filter(Boolean);
|
||||
(window as any).rybbit?.identify(user.id, {
|
||||
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
|
||||
...(user.email ? { email: user.email } : {}),
|
||||
});
|
||||
} else {
|
||||
(window as any).rybbit?.identify(anonymousId);
|
||||
}
|
||||
|
||||
const date = getDate();
|
||||
const reference = getReference();
|
||||
|
||||
// Restore whether today's completion was already submitted to the server.
|
||||
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
||||
|
||||
// Restore the chapter bonus guess result. The stored value includes the
|
||||
// chapter the player selected, so we can re-derive whether it was correct.
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||
if (chapterGuessCompleted) {
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// On mount (and if the date or correct answer changes), load today's guesses
|
||||
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
|
||||
// each stored book ID against the correct answer.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const date = getDate();
|
||||
const correctBookId = getCorrectBookId();
|
||||
const key = `bibdle-guesses-${date}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) {
|
||||
guesses = [];
|
||||
return;
|
||||
}
|
||||
|
||||
let savedIds: string[] = JSON.parse(saved);
|
||||
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
|
||||
guesses = savedIds
|
||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||
.filter((g): g is Guess => g !== null);
|
||||
});
|
||||
|
||||
// Persist guesses to localStorage whenever they change. Only the book IDs are
|
||||
// stored — the full Guess shape is re-derived on load (see effect above).
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const date = getDate();
|
||||
localStorage.setItem(
|
||||
`bibdle-guesses-${date}`,
|
||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
||||
);
|
||||
});
|
||||
|
||||
// Called after stats are successfully submitted to the server so that
|
||||
// returning to the page doesn't trigger a duplicate submission.
|
||||
function markStatsSubmitted() {
|
||||
if (!browser) return;
|
||||
statsSubmitted = true;
|
||||
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
||||
}
|
||||
|
||||
// Marks the win as tracked for analytics. Returns true the first time (new
|
||||
// win), false on subsequent calls so the analytics event fires exactly once.
|
||||
function markWinTracked() {
|
||||
if (!browser) return;
|
||||
const key = `bibdle-win-tracked-${getDate()}`;
|
||||
if (localStorage.getItem(key) === "true") return false;
|
||||
localStorage.setItem(key, "true");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if the win has already been tracked in a previous render/session.
|
||||
// Used to skip the animation delay when returning to an already-won game.
|
||||
function isWinAlreadyTracked(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
||||
}
|
||||
|
||||
// Overwrites local state with the server's authoritative guess record.
|
||||
// Called when a logged-in user opens the game on a new device so their
|
||||
// progress from another device is restored.
|
||||
function hydrateFromServer(guessIds: string[]) {
|
||||
if (!browser) return;
|
||||
const correctBookId = getCorrectBookId();
|
||||
const date = getDate();
|
||||
guesses = guessIds
|
||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||
.filter((g): g is Guess => g !== null);
|
||||
}
|
||||
|
||||
// Called by the WinScreen after the player submits their chapter bonus guess.
|
||||
// Reads the result written to localStorage by WinScreen and updates reactive state.
|
||||
function onChapterGuessCompleted() {
|
||||
if (!browser) return;
|
||||
chapterGuessCompleted = true;
|
||||
const reference = getReference();
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get guesses() { return guesses; },
|
||||
set guesses(v: Guess[]) { guesses = v; },
|
||||
get anonymousId() { return anonymousId; },
|
||||
get statsSubmitted() { return statsSubmitted; },
|
||||
get chapterGuessCompleted() { return chapterGuessCompleted; },
|
||||
get chapterCorrect() { return chapterCorrect; },
|
||||
markStatsSubmitted,
|
||||
markWinTracked,
|
||||
isWinAlreadyTracked,
|
||||
onChapterGuessCompleted,
|
||||
hydrateFromServer,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { bibleBooks, type BibleBook } from '$lib/types/bible';
|
||||
|
||||
export interface Guess {
|
||||
book: BibleBook;
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
firstLetterMatch: boolean;
|
||||
}
|
||||
|
||||
export function getBookById(id: string): BibleBook | undefined {
|
||||
return bibleBooks.find((b) => b.id === id);
|
||||
}
|
||||
@@ -10,7 +18,47 @@ export function isAdjacent(id1: string, id2: string): boolean {
|
||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||
}
|
||||
|
||||
export function getGrade(numGuesses: number, popularity: number): string {
|
||||
export function getFirstLetter(bookName: string): string {
|
||||
const match = bookName.match(/[a-zA-Z]/);
|
||||
return match ? match[0] : bookName[0];
|
||||
}
|
||||
|
||||
export function evaluateGuess(guessBookId: string, correctBookId: string): Guess | null {
|
||||
const book = getBookById(guessBookId);
|
||||
const correctBook = getBookById(correctBookId);
|
||||
if (!book || !correctBook) return null;
|
||||
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(guessBookId, correctBookId);
|
||||
|
||||
// Special case: if correct book is in the Epistles + starts with "1",
|
||||
// any guess starting with "1" counts as first letter match
|
||||
const correctIsEpistlesWithNumber =
|
||||
(correctBook.section === "Pauline Epistles" ||
|
||||
correctBook.section === "General Epistles") &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(book.section === "Pauline Epistles" ||
|
||||
book.section === "General Epistles") &&
|
||||
book.name[0] === "1";
|
||||
|
||||
const firstLetterMatch =
|
||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||
? true
|
||||
: getFirstLetter(book.name).toUpperCase() ===
|
||||
getFirstLetter(correctBook.name).toUpperCase();
|
||||
|
||||
return {
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
firstLetterMatch,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGrade(numGuesses: number): string {
|
||||
if (numGuesses === 1) return "S+";
|
||||
if (numGuesses === 2) return "A+";
|
||||
if (numGuesses === 3) return "A";
|
||||
@@ -49,4 +97,4 @@ export function generateUUID(): string {
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
98
src/lib/utils/share.ts
Normal file
98
src/lib/utils/share.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Guess } from './game';
|
||||
|
||||
export function getVerseSnippet(verseText: string): string {
|
||||
const words = verseText.trim().split(/\s+/);
|
||||
const slice = words.slice(0, 25);
|
||||
const text = slice.join(' ');
|
||||
|
||||
// Returns character index immediately after the Nth word (1-indexed)
|
||||
function posAfterWord(n: number): number {
|
||||
let pos = 0;
|
||||
for (let w = 0; w < Math.min(n, slice.length); w++) {
|
||||
if (w > 0) pos++; // space between words
|
||||
pos += slice[w].length;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
const start = posAfterWord(9);
|
||||
const end = posAfterWord(25);
|
||||
|
||||
// Find first punctuation mark between words 10 and 25
|
||||
const range = text.substring(start, end);
|
||||
const match = range.match(/[,;:.!?—–-]/);
|
||||
|
||||
function withClosedQuotes(snippet: string): string {
|
||||
const opens = (snippet.match(/\u201C/g) ?? []).length;
|
||||
const closes = (snippet.match(/\u201D/g) ?? []).length;
|
||||
const closeQuote = opens > closes ? '\u201D' : '';
|
||||
return `\u201C${snippet}...${closeQuote}\u201D`;
|
||||
}
|
||||
|
||||
if (match && match.index !== undefined) {
|
||||
const cutPos = start + match.index;
|
||||
return withClosedQuotes(text.substring(0, cutPos).trimEnd());
|
||||
}
|
||||
|
||||
return withClosedQuotes(text);
|
||||
}
|
||||
|
||||
export function generateShareText(params: {
|
||||
guesses: Guess[];
|
||||
correctBookId: string;
|
||||
dailyVerseDate: string;
|
||||
chapterCorrect: boolean;
|
||||
isLoggedIn: boolean;
|
||||
streak?: number;
|
||||
origin: string;
|
||||
verseText: string;
|
||||
}): string {
|
||||
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
|
||||
|
||||
const emojis = guesses
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((guess) => {
|
||||
if (guess.book.id === correctBookId) return "✅";
|
||||
if (guess.adjacent) return "‼️";
|
||||
if (guess.sectionMatch) return "🟩";
|
||||
if (guess.testamentMatch) return "🟧";
|
||||
return "🟥";
|
||||
})
|
||||
.join("");
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formattedDate = dateFormatter.format(
|
||||
new Date(`${dailyVerseDate}T00:00:00`),
|
||||
);
|
||||
|
||||
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
||||
|
||||
const guessWord = guesses.length === 1 ? "guess" : "guesses";
|
||||
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
|
||||
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
|
||||
|
||||
const lines = [
|
||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||
`${guesses.length} ${guessWord},${streakPart}`,
|
||||
`${emojis}${chapterStar}`
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function shareResult(shareText: string): Promise<void> {
|
||||
if ("share" in navigator) {
|
||||
await (navigator as any).share({ text: shareText });
|
||||
} else {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyToClipboard(shareText: string): Promise<void> {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
}
|
||||
68
src/lib/utils/stats-client.ts
Normal file
68
src/lib/utils/stats-client.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface StatsData {
|
||||
solveRank: number;
|
||||
guessRank: number;
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
tiedCount: number;
|
||||
percentile: number;
|
||||
guesses?: string[]; // Present when fetching an existing completion (cross-device sync)
|
||||
}
|
||||
|
||||
export async function submitCompletion(params: {
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
guessCount: number;
|
||||
guesses: string[];
|
||||
}): Promise<StatsData | null> {
|
||||
try {
|
||||
const response = await fetch("/api/submit-completion", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.stats) {
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
// Already submitted from another device — fetch existing stats
|
||||
return fetchExistingStats({ anonymousId: params.anonymousId, date: params.date });
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error("Stats server error:", result.error);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Stats submission failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchExistingStats(params: {
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
}): Promise<StatsData | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/stats?anonymousId=${params.anonymousId}&date=${params.date}`,
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.stats) {
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error("Stats server error:", result.error);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Stats fetch failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
86
src/lib/utils/stats.ts
Normal file
86
src/lib/utils/stats.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export interface UserStats {
|
||||
totalSolves: number;
|
||||
avgGuesses: number;
|
||||
gradeDistribution: {
|
||||
'S++': number;
|
||||
'S+': number;
|
||||
'A+': number;
|
||||
'A': number;
|
||||
'B+': number;
|
||||
'B': number;
|
||||
'C+': number;
|
||||
'C': number;
|
||||
};
|
||||
currentStreak: number;
|
||||
bestStreak: number;
|
||||
recentCompletions: Array<{
|
||||
date: string;
|
||||
guessCount: number;
|
||||
grade: string;
|
||||
}>;
|
||||
worstDay: {
|
||||
date: string;
|
||||
guessCount: number;
|
||||
} | null;
|
||||
bestBook: {
|
||||
bookId: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
} | null;
|
||||
mostSeenBook: {
|
||||
bookId: string;
|
||||
count: number;
|
||||
} | null;
|
||||
totalBooksSeenOT: number;
|
||||
totalBooksSeenNT: number;
|
||||
}
|
||||
|
||||
export function getGradeColor(grade: string): string {
|
||||
switch (grade) {
|
||||
case 'S++': return 'text-purple-600 bg-purple-100';
|
||||
case 'S+': return 'text-yellow-600 bg-yellow-100';
|
||||
case 'A+': return 'text-green-600 bg-green-100';
|
||||
case 'A': return 'text-green-500 bg-green-50';
|
||||
case 'B+': return 'text-blue-600 bg-blue-100';
|
||||
case 'B': return 'text-blue-500 bg-blue-50';
|
||||
case 'C+': return 'text-orange-600 bg-orange-100';
|
||||
case 'C': return 'text-red-600 bg-red-100';
|
||||
default: return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
export function getStreakMessage(currentStreak: number): string {
|
||||
if (currentStreak === 0) {
|
||||
return "Start your streak today!";
|
||||
} else if (currentStreak === 1) {
|
||||
return "Keep it going!";
|
||||
} else if (currentStreak < 7) {
|
||||
return `${currentStreak} days strong!`;
|
||||
} else if (currentStreak < 30) {
|
||||
return `${currentStreak} day streak - amazing!`;
|
||||
} else {
|
||||
return `${currentStreak} days - you're unstoppable!`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPerformanceMessage(avgGuesses: number): string {
|
||||
if (avgGuesses <= 2) {
|
||||
return "Exceptional performance!";
|
||||
} else if (avgGuesses <= 4) {
|
||||
return "Great performance!";
|
||||
} else if (avgGuesses <= 6) {
|
||||
return "Good performance!";
|
||||
} else if (avgGuesses <= 8) {
|
||||
return "Room for improvement!";
|
||||
} else {
|
||||
return "Keep practicing!";
|
||||
}
|
||||
}
|
||||
15
src/lib/utils/streak.ts
Normal file
15
src/lib/utils/streak.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export async function fetchStreak(anonymousId: string, localDate: string): Promise<number> {
|
||||
const params = new URLSearchParams({ anonymousId, localDate });
|
||||
const res = await fetch(`/api/streak?${params}`);
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
return typeof data.streak === 'number' ? data.streak : 0;
|
||||
}
|
||||
|
||||
export async function fetchStreakPercentile(streak: number, localDate: string): Promise<number | null> {
|
||||
const params = new URLSearchParams({ streak: String(streak), localDate });
|
||||
const res = await fetch(`/api/streak-percentile?${params}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return typeof data.percentile === 'number' ? data.percentile : null;
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
const script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = 'https://umami.snail.city/script.js';
|
||||
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
||||
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
});
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<script
|
||||
defer
|
||||
src="https://umami.snail.city/script.js"
|
||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
||||
data-domains="bibdle.com,www.bibdle.com"
|
||||
></script>
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
</svelte:head>
|
||||
{@render children()}
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, sql, asc } from 'drizzle-orm';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
async function getTodayVerse(): Promise<DailyVerse> {
|
||||
// Get the current date (server-side)
|
||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
|
||||
// If there's an existing verse for the current date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: crypto.randomUUID(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const dailyVerse = await getTodayVerse();
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
dailyVerse,
|
||||
correctBookId: dailyVerse.bookId,
|
||||
correctBook
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
|
||||
@@ -91,13 +58,20 @@ export const actions: Actions = {
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
|
||||
// Count ties: how many have the SAME guessCount (excluding self)
|
||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||
|
||||
// Average guesses
|
||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||
|
||||
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
||||
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,442 +1,441 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
||||
import type { PageProps } from "./$types";
|
||||
import { browser } from "$app/environment";
|
||||
import { enhance } from "$app/forms";
|
||||
|
||||
import type { PageProps } from "./$types";
|
||||
import { browser } from "$app/environment";
|
||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
||||
import WinScreen from "$lib/components/WinScreen.svelte";
|
||||
import Credits from "$lib/components/Credits.svelte";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
|
||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
||||
import CountdownTimer from "$lib/components/CountdownTimer.svelte";
|
||||
import WinScreen from "$lib/components/WinScreen.svelte";
|
||||
import Feedback from "$lib/components/Feedback.svelte";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import { getGrade } from "$lib/utils/game";
|
||||
import { evaluateGuess } from "$lib/utils/game";
|
||||
import {
|
||||
generateShareText,
|
||||
shareResult,
|
||||
copyToClipboard as clipboardCopy,
|
||||
} from "$lib/utils/share";
|
||||
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
|
||||
import {
|
||||
submitCompletion,
|
||||
fetchExistingStats,
|
||||
type StatsData,
|
||||
} from "$lib/utils/stats-client";
|
||||
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
interface Guess {
|
||||
book: BibleBook;
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
}
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let correctBookId = $derived(data.correctBookId);
|
||||
let correctBook = $derived(data.correctBook);
|
||||
let user = $derived(data.user);
|
||||
let session = $derived(data.session);
|
||||
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let correctBookId = $derived(data.correctBookId);
|
||||
let searchQuery = $state("");
|
||||
let copied = $state(false);
|
||||
let isDev = $state(false);
|
||||
let authModalOpen = $state(false);
|
||||
let showWinScreen = $state(false);
|
||||
let statsData = $state<StatsData | null>(null);
|
||||
let streak = $state(0);
|
||||
let streakPercentile = $state<number | null>(null);
|
||||
|
||||
let guesses = $state<Guess[]>([]);
|
||||
const persistence = createGamePersistence(
|
||||
() => dailyVerse.date,
|
||||
() => dailyVerse.reference,
|
||||
() => correctBookId,
|
||||
() => user,
|
||||
);
|
||||
|
||||
let searchQuery = $state("");
|
||||
let guessedIds = $derived(
|
||||
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
|
||||
);
|
||||
|
||||
let copied = $state(false);
|
||||
let isDev = $state(false);
|
||||
const currentDate = $derived(
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
let anonymousId = $state("");
|
||||
let statsSubmitted = $state(false);
|
||||
let statsData = $state<{
|
||||
solveRank: number;
|
||||
guessRank: number;
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
} | null>(null);
|
||||
let isWon = $derived(
|
||||
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||
);
|
||||
let blurChapter = $derived(
|
||||
isWon &&
|
||||
persistence.guesses.length === 1 &&
|
||||
!persistence.chapterGuessCompleted,
|
||||
);
|
||||
|
||||
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
|
||||
async function submitGuess(bookId: string) {
|
||||
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||
|
||||
const currentDate = $derived(
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
);
|
||||
const guess = evaluateGuess(bookId, correctBookId);
|
||||
if (!guess) return;
|
||||
|
||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
||||
let grade = $derived(
|
||||
isWon
|
||||
? getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0)
|
||||
: ""
|
||||
);
|
||||
if (persistence.guesses.length === 0) {
|
||||
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
||||
if (
|
||||
browser &&
|
||||
localStorage.getItem(key) !== "true" &&
|
||||
(window as any).umami
|
||||
) {
|
||||
(window as any).umami.track("First guess");
|
||||
(window as any).rybbit?.event("First guess");
|
||||
localStorage.setItem(key, "true");
|
||||
}
|
||||
}
|
||||
|
||||
function getBookById(id: string): BibleBook | undefined {
|
||||
return bibleBooks.find((b) => b.id === id);
|
||||
}
|
||||
persistence.guesses = [guess, ...persistence.guesses];
|
||||
searchQuery = "";
|
||||
|
||||
function isAdjacent(id1: string, id2: string): boolean {
|
||||
const b1 = getBookById(id1);
|
||||
const b2 = getBookById(id2);
|
||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||
}
|
||||
if (
|
||||
guess.book.id === correctBookId &&
|
||||
browser &&
|
||||
persistence.anonymousId
|
||||
) {
|
||||
statsData = await submitCompletion({
|
||||
anonymousId: persistence.anonymousId,
|
||||
date: dailyVerse.date,
|
||||
guessCount: persistence.guesses.length,
|
||||
guesses: persistence.guesses.map((g) => g.book.id),
|
||||
});
|
||||
if (statsData) {
|
||||
persistence.markStatsSubmitted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function submitGuess(bookId: string) {
|
||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
||||
// Reload when the user returns to a stale tab on a new calendar day
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const book = getBookById(bookId);
|
||||
if (!book) return;
|
||||
const loadedDate = new Date().toLocaleDateString("en-CA");
|
||||
|
||||
const correctBook = getBookById(correctBookId);
|
||||
if (!correctBook) return;
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) return;
|
||||
const now = new Date().toLocaleDateString("en-CA");
|
||||
if (now !== loadedDate) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(book.id, correctBookId);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
onVisibilityChange,
|
||||
);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`
|
||||
);
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
isDev =
|
||||
window.location.host === "localhost:5173" ||
|
||||
window.location.host === "test.bibdle.com";
|
||||
});
|
||||
|
||||
if (guesses.length === 0) {
|
||||
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
||||
if (
|
||||
localStorage.getItem(key) !== "true" &&
|
||||
browser &&
|
||||
(window as any).umami
|
||||
) {
|
||||
(window as any).umami.track("First guess");
|
||||
localStorage.setItem(key, "true");
|
||||
}
|
||||
}
|
||||
// Fetch stats on page load if user already won in a previous session (same device)
|
||||
$effect(() => {
|
||||
if (
|
||||
!browser ||
|
||||
!isWon ||
|
||||
!persistence.anonymousId ||
|
||||
statsData ||
|
||||
!persistence.statsSubmitted
|
||||
)
|
||||
return;
|
||||
fetchExistingStats({
|
||||
anonymousId: persistence.anonymousId,
|
||||
date: dailyVerse.date,
|
||||
}).then((data) => {
|
||||
statsData = data;
|
||||
});
|
||||
});
|
||||
|
||||
guesses = [
|
||||
{
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
},
|
||||
...guesses,
|
||||
];
|
||||
// For logged-in users on a new device: restore today's game state from the server.
|
||||
// Runs even when isWon is true so that logging in after completing the game on another
|
||||
// device always replaces local localStorage with the authoritative DB record.
|
||||
let crossDeviceCheckDate = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
if (
|
||||
!browser ||
|
||||
!user ||
|
||||
!dailyVerse?.date ||
|
||||
crossDeviceCheckDate === dailyVerse.date ||
|
||||
!persistence.anonymousId
|
||||
)
|
||||
return;
|
||||
crossDeviceCheckDate = dailyVerse.date;
|
||||
fetchExistingStats({
|
||||
anonymousId: persistence.anonymousId,
|
||||
date: dailyVerse.date,
|
||||
}).then((data) => {
|
||||
if (data?.guesses?.length) {
|
||||
persistence.hydrateFromServer(data.guesses);
|
||||
statsData = data;
|
||||
persistence.markStatsSubmitted();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
searchQuery = "";
|
||||
}
|
||||
// Delay showing win screen until GuessesTable animation completes
|
||||
$effect(() => {
|
||||
if (!isWon) {
|
||||
showWinScreen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
function generateUUID(): string {
|
||||
// Try native randomUUID if available
|
||||
if (typeof window.crypto.randomUUID === "function") {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
if (persistence.isWinAlreadyTracked()) {
|
||||
showWinScreen = true;
|
||||
} else {
|
||||
const animationDelay = 1800;
|
||||
const timeoutId = setTimeout(() => {
|
||||
showWinScreen = true;
|
||||
}, animationDelay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback UUID v4 generator for older browsers
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
// Track win analytics
|
||||
$effect(() => {
|
||||
if (!browser || !isWon) return;
|
||||
const isNew = persistence.markWinTracked();
|
||||
if (isNew && (window as any).umami) {
|
||||
(window as any).umami.track("Guessed correctly", {
|
||||
totalGuesses: persistence.guesses.length,
|
||||
});
|
||||
(window as any).rybbit?.event("Guessed correctly", {
|
||||
totalGuesses: persistence.guesses.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
const key = "bibdle-anonymous-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
// Fetch streak when the player wins
|
||||
$effect(() => {
|
||||
if (!browser || !isWon || !persistence.anonymousId) return;
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||
streak = result;
|
||||
if (result >= 2) {
|
||||
fetchStreakPercentile(result, localDate).then((p) => {
|
||||
streakPercentile = p;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize anonymous ID
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
||||
});
|
||||
function getShareText(): string {
|
||||
return generateShareText({
|
||||
guesses: persistence.guesses,
|
||||
correctBookId,
|
||||
dailyVerseDate: dailyVerse.date,
|
||||
chapterCorrect: persistence.chapterCorrect,
|
||||
isLoggedIn: !!user,
|
||||
streak,
|
||||
origin: window.location.origin,
|
||||
verseText: dailyVerse.verseText,
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
isDev = window.location.host === "localhost:5173";
|
||||
});
|
||||
function handleShare() {
|
||||
if (copied || !browser) return;
|
||||
const useClipboard = !("share" in navigator);
|
||||
if (useClipboard) {
|
||||
copied = true;
|
||||
}
|
||||
shareResult(getShareText())
|
||||
.then(() => {
|
||||
if (useClipboard) {
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 5000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (useClipboard) {
|
||||
copied = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load saved guesses
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const key = `bibdle-guesses-${dailyVerse.date}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
let savedIds: string[] = JSON.parse(saved);
|
||||
savedIds = Array.from(new Set(savedIds));
|
||||
guesses = savedIds.map((bookId: string) => {
|
||||
const book = getBookById(bookId)!;
|
||||
const correctBook = getBookById(correctBookId)!;
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(bookId, correctBookId);
|
||||
return {
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(
|
||||
`bibdle-guesses-${dailyVerse.date}`,
|
||||
JSON.stringify(guesses.map((g) => g.book.id))
|
||||
);
|
||||
});
|
||||
|
||||
// Auto-submit stats when user wins
|
||||
$effect(() => {
|
||||
console.log("Stats effect triggered:", {
|
||||
browser,
|
||||
isWon,
|
||||
anonymousId,
|
||||
statsSubmitted,
|
||||
statsData,
|
||||
});
|
||||
|
||||
if (!browser || !isWon || !anonymousId) {
|
||||
console.log("Basic conditions not met");
|
||||
return;
|
||||
}
|
||||
|
||||
if (statsSubmitted && !statsData) {
|
||||
console.log("Fetching existing stats...");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`
|
||||
);
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
|
||||
if (result.success && result.stats) {
|
||||
console.log("Setting stats data:", result.stats);
|
||||
statsData = result.stats;
|
||||
localStorage.setItem(
|
||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
||||
"true"
|
||||
);
|
||||
} else if (result.error) {
|
||||
console.error("Server error:", result.error);
|
||||
} else {
|
||||
console.error("Unexpected response format:", result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Stats fetch failed:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Submitting stats...");
|
||||
|
||||
async function submitStats() {
|
||||
try {
|
||||
const payload = {
|
||||
anonymousId,
|
||||
date: dailyVerse.date,
|
||||
guessCount: guesses.length,
|
||||
};
|
||||
|
||||
console.log("Sending POST request with:", payload);
|
||||
|
||||
const response = await fetch("/api/submit-completion", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
|
||||
if (result.success && result.stats) {
|
||||
console.log("Setting stats data:", result.stats);
|
||||
statsData = result.stats;
|
||||
statsSubmitted = true;
|
||||
localStorage.setItem(
|
||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
||||
"true"
|
||||
);
|
||||
} else if (result.error) {
|
||||
console.error("Server error:", result.error);
|
||||
} else {
|
||||
console.error("Unexpected response format:", result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Stats submission failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
submitStats();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser || !isWon) return;
|
||||
const key = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
if (localStorage.getItem(key) === "true") return;
|
||||
if ((window as any).umami) {
|
||||
(window as any).umami.track("Guessed correctly", {
|
||||
totalGuesses: guesses.length,
|
||||
});
|
||||
}
|
||||
localStorage.setItem(key, "true");
|
||||
});
|
||||
|
||||
function generateShareText(): string {
|
||||
const emojis = guesses
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((guess) => {
|
||||
if (guess.book.id === correctBookId) return "✅";
|
||||
if (guess.adjacent) return "‼️";
|
||||
if (guess.sectionMatch) return "🟩";
|
||||
if (guess.testamentMatch) return "🟧";
|
||||
return "🟥";
|
||||
})
|
||||
.join("");
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formattedDate = dateFormatter.format(
|
||||
new Date(`${dailyVerse.date}T00:00:00`)
|
||||
);
|
||||
const siteUrl = window.location.origin;
|
||||
return [
|
||||
`📖 Bibdle | ${formattedDate} 📖`,
|
||||
`${grade} (${guesses.length} ${guesses.length == 1 ? "guess" : "guesses"})`,
|
||||
`${emojis}`,
|
||||
siteUrl,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function share() {
|
||||
if (!browser) return;
|
||||
|
||||
const shareText = generateShareText();
|
||||
|
||||
try {
|
||||
if ("share" in navigator) {
|
||||
await (navigator as any).share({ text: shareText });
|
||||
} else {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Share failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!browser) return;
|
||||
|
||||
const shareText = generateShareText();
|
||||
|
||||
try {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error("Copy to clipboard failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
if (copied || !browser) return;
|
||||
const useClipboard = !("share" in navigator);
|
||||
if (useClipboard) {
|
||||
copied = true;
|
||||
}
|
||||
share()
|
||||
.then(() => {
|
||||
if (useClipboard) {
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 5000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (useClipboard) {
|
||||
copied = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearLocalStorage() {
|
||||
if (!browser) return;
|
||||
// Clear all bibdle-related localStorage items
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith("bibdle-")) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
// Reload the page to reset state
|
||||
window.location.reload();
|
||||
}
|
||||
async function handleCopyToClipboard() {
|
||||
if (!browser) return;
|
||||
try {
|
||||
await clipboardCopy(getShareText());
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error("Copy to clipboard failed:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||
<!-- <meta
|
||||
name="description"
|
||||
content="Guess which book of the Bible a verse comes from."
|
||||
/> -->
|
||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
|
||||
<div class="w-full max-w-3xl mx-auto px-4">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
|
||||
>
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="text-center mb-8">
|
||||
<span class="big-text"
|
||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||
>
|
||||
</div>
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950 py-8">
|
||||
<div class="w-full max-w-3xl mx-auto px-4">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 animate-fade-in-up"
|
||||
>
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||
<span class="big-text"
|
||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<VerseDisplay {data} {isWon} />
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="animate-fade-in-up animate-delay-200">
|
||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||
</div>
|
||||
|
||||
{#if !isWon}
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||
{:else}
|
||||
<WinScreen
|
||||
{grade}
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
{copyToClipboard}
|
||||
bind:copied
|
||||
{statsSubmitted}
|
||||
guessCount={guesses.length}
|
||||
/>
|
||||
<CountdownTimer />
|
||||
{/if}
|
||||
{#if !isWon}
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
|
||||
</div>
|
||||
{:else if showWinScreen}
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<WinScreen
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
copyToClipboard={handleCopyToClipboard}
|
||||
bind:copied
|
||||
statsSubmitted={persistence.statsSubmitted}
|
||||
guessCount={persistence.guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||
shareText={getShareText()}
|
||||
verseText={dailyVerse.verseText}
|
||||
{streak}
|
||||
{streakPercentile}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GuessesTable {guesses} {correctBookId} />
|
||||
{#if isWon}
|
||||
<Feedback />
|
||||
{/if}
|
||||
{#if isDev}
|
||||
<button
|
||||
onclick={clearLocalStorage}
|
||||
class="mt-4 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-bold transition-colors"
|
||||
>
|
||||
Clear LocalStorage
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="animate-fade-in-up animate-delay-600">
|
||||
<GuessesTable guesses={persistence.guesses} {correctBookId} />
|
||||
</div>
|
||||
|
||||
{#if isWon}
|
||||
<div class="animate-fade-in-up animate-delay-800">
|
||||
<Credits />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isDev}
|
||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<a
|
||||
href="/stats?{user
|
||||
? `userId=${user.id}`
|
||||
: `anonymousId=${persistence.anonymousId}`}&tz={encodeURIComponent(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)}"
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📊 View Stats
|
||||
</a>
|
||||
|
||||
{#if user}
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/logout"
|
||||
use:enhance
|
||||
class="w-full md:w-auto"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🚪 Sign Out
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (authModalOpen = true)}
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🔐 Sign In
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded border dark:border-gray-700"
|
||||
>
|
||||
<div><strong>Debug Info:</strong></div>
|
||||
<div>
|
||||
User: {user
|
||||
? `${user.email} (ID: ${user.id})`
|
||||
: "Not signed in"}
|
||||
</div>
|
||||
<div>
|
||||
Session: {session
|
||||
? `Expires ${session.expiresAt.toLocaleDateString()}`
|
||||
: "No session"}
|
||||
</div>
|
||||
<div>
|
||||
Anonymous ID: {persistence.anonymousId || "Not set"}
|
||||
</div>
|
||||
<div>
|
||||
Client Local Time: {new Date().toLocaleString("en-US", {
|
||||
timeZone:
|
||||
Intl.DateTimeFormat().resolvedOptions()
|
||||
.timeZone,
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
Client Local Date: {new Date().toLocaleDateString(
|
||||
"en-CA",
|
||||
)}
|
||||
</div>
|
||||
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||
<div>Streak: {streak}</div>
|
||||
</div>
|
||||
<DevButtons anonymousId={persistence.anonymousId} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if user && session}
|
||||
<div
|
||||
class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
Signed in as {[user.firstName, user.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ")}{user.email
|
||||
? ` (${user.email})`
|
||||
: ""}{user.appleId ? " using Apple" : ""} |
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/logout"
|
||||
use:enhance
|
||||
class="inline"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
|
||||
>Sign out</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} anonymousId={persistence.anonymousId} />
|
||||
|
||||
23
src/routes/+page.ts
Normal file
23
src/routes/+page.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
// Disable SSR so the load function runs on the client with the correct local date
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ fetch, data }) => {
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
|
||||
const res = await fetch('/api/daily-verse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date: localDate }),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
return {
|
||||
...data,
|
||||
dailyVerse: result.dailyVerse,
|
||||
correctBookId: result.correctBookId,
|
||||
correctBook: result.correctBook,
|
||||
};
|
||||
};
|
||||
24
src/routes/api/daily-verse/+server.ts
Normal file
24
src/routes/api/daily-verse/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const { date } = body;
|
||||
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dateStr = date;
|
||||
|
||||
const dailyVerse = await getVerseForDate(dateStr);
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
return json({
|
||||
dailyVerse,
|
||||
correctBookId: dailyVerse.bookId,
|
||||
correctBook,
|
||||
});
|
||||
};
|
||||
66
src/routes/api/dev/seed-history/+server.ts
Normal file
66
src/routes/api/dev/seed-history/+server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
|
||||
|
||||
// A spread of book IDs to use as fake guesses
|
||||
const SAMPLE_BOOK_IDS = [
|
||||
'GEN', 'EXO', 'PSA', 'PRO', 'ISA', 'JER', 'MAT', 'MRK', 'LUK', 'JHN',
|
||||
'ROM', 'GAL', 'EPH', 'PHP', 'REV', 'ACT', 'HEB', 'JAS', '1CO', '2CO',
|
||||
];
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const host = request.headers.get('host') ?? '';
|
||||
if (!DEV_HOSTS.includes(host)) {
|
||||
return json({ error: 'Not allowed in production' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { anonymousId } = await request.json();
|
||||
|
||||
if (!anonymousId || typeof anonymousId !== 'string') {
|
||||
return json({ error: 'anonymousId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const inserted: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
|
||||
const guessCount = Math.floor(Math.random() * 6) + 1; // 1-6 guesses
|
||||
// Pick `guessCount` random books (last one is the "correct" answer)
|
||||
const shuffled = [...SAMPLE_BOOK_IDS].sort(() => Math.random() - 0.5);
|
||||
const guesses = shuffled.slice(0, guessCount);
|
||||
|
||||
try {
|
||||
await db.insert(dailyCompletions).values({
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId,
|
||||
date,
|
||||
guessCount,
|
||||
guesses: JSON.stringify(guesses),
|
||||
completedAt: new Date(d.getTime() + 12 * 60 * 60 * 1000), // noon on that day
|
||||
});
|
||||
inserted.push(date);
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
|
||||
skipped.push(date);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true, inserted, skipped });
|
||||
} catch (err) {
|
||||
console.error('Error seeding history:', err);
|
||||
return json({ error: 'Failed to seed history' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
68
src/routes/api/imposter/+server.ts
Normal file
68
src/routes/api/imposter/+server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getRandomVersesFromBook } from '$lib/server/xml-bible';
|
||||
|
||||
interface VerseOption {
|
||||
text: string;
|
||||
isImposter: boolean;
|
||||
ref: string;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
// Select two different random books (1-66)
|
||||
let book1Num = Math.floor(Math.random() * 66) + 1;
|
||||
let book2Num = Math.floor(Math.random() * 66) + 1;
|
||||
while (book2Num === book1Num) {
|
||||
book2Num = Math.floor(Math.random() * 66) + 1;
|
||||
}
|
||||
|
||||
// Randomly decide which is majority
|
||||
const majorityBookNum = Math.random() < 0.5 ? book1Num : book2Num;
|
||||
const imposterBookNum = majorityBookNum === book1Num ? book2Num : book1Num;
|
||||
|
||||
// Get 3 random verses from majority book
|
||||
const options: VerseOption[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const verseData = getRandomVersesFromBook(majorityBookNum, 1);
|
||||
if (!verseData) {
|
||||
throw new Error('Failed to get majority verse');
|
||||
}
|
||||
options.push({
|
||||
text: verseData.verses[0],
|
||||
isImposter: false,
|
||||
ref: `${verseData.bookName} ${verseData.chapter}:${verseData.startVerse}`
|
||||
});
|
||||
}
|
||||
|
||||
// Get 1 random verse from imposter book
|
||||
const imposterVerseData = getRandomVersesFromBook(imposterBookNum, 1);
|
||||
if (!imposterVerseData) {
|
||||
throw new Error('Failed to get imposter verse');
|
||||
}
|
||||
options.push({
|
||||
text: imposterVerseData.verses[0],
|
||||
isImposter: true,
|
||||
ref: `${imposterVerseData.bookName} ${imposterVerseData.chapter}:${imposterVerseData.startVerse}`
|
||||
});
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = options.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[options[i], options[j]] = [options[j], options[i]];
|
||||
}
|
||||
|
||||
const verses = options.map(o => o.text);
|
||||
const refs = options.map(o => o.ref);
|
||||
const imposterIndex = options.findIndex(o => o.isImposter);
|
||||
|
||||
return json({
|
||||
verses,
|
||||
refs,
|
||||
imposterIndex
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Imposter API error:', error);
|
||||
return json({ error: 'Failed to generate imposter game' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
63
src/routes/api/stats/+server.ts
Normal file
63
src/routes/api/stats/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { and, eq, asc } from 'drizzle-orm';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const anonymousId = url.searchParams.get('anonymousId');
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
if (!anonymousId || !date) {
|
||||
return json({ error: 'Invalid data' }, { status: 400 });
|
||||
}
|
||||
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(and(
|
||||
eq(dailyCompletions.anonymousId, anonymousId),
|
||||
eq(dailyCompletions.date, date)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (userCompletions.length === 0) {
|
||||
return json({ error: 'No completion found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const userCompletion = userCompletions[0];
|
||||
const guessCount = userCompletion.guessCount;
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, date))
|
||||
.orderBy(asc(dailyCompletions.completedAt));
|
||||
|
||||
const totalSolves = allCompletions.length;
|
||||
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
|
||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||
|
||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||
|
||||
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||
|
||||
const guesses = userCompletion.guesses ? JSON.parse(userCompletion.guesses) : undefined;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile, guesses }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats:', err);
|
||||
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
103
src/routes/api/streak-percentile/+server.ts
Normal file
103
src/routes/api/streak-percentile/+server.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const streakParam = url.searchParams.get('streak');
|
||||
const localDate = url.searchParams.get('localDate');
|
||||
|
||||
if (!streakParam || !localDate) {
|
||||
error(400, 'Missing streak or localDate');
|
||||
}
|
||||
|
||||
const targetStreak = parseInt(streakParam, 10);
|
||||
if (isNaN(targetStreak) || targetStreak < 1) {
|
||||
error(400, 'Invalid streak');
|
||||
}
|
||||
|
||||
// Fetch all completions ordered by anonymous_id and date desc
|
||||
// so we can walk each user's history to compute their current streak.
|
||||
const rows = await db
|
||||
.select({
|
||||
anonymousId: dailyCompletions.anonymousId,
|
||||
date: dailyCompletions.date,
|
||||
})
|
||||
.from(dailyCompletions)
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
// Group dates by user
|
||||
const byUser = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const list = byUser.get(row.anonymousId);
|
||||
if (list) {
|
||||
list.push(row.date);
|
||||
} else {
|
||||
byUser.set(row.anonymousId, [row.date]);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the current streak for each user.
|
||||
// Start from today; if the user hasn't played today yet, try yesterday so
|
||||
// that streaks aren't zeroed out mid-day before the player has had a chance
|
||||
// to complete today's puzzle.
|
||||
const yesterday = new Date(`${localDate}T00:00:00`);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toLocaleDateString('en-CA');
|
||||
|
||||
const thirtyDaysAgo = new Date(`${localDate}T00:00:00`);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toLocaleDateString('en-CA');
|
||||
|
||||
// For each user, compute their current streak and whether they've played
|
||||
// within the last 30 days. "Eligible players" = active streak OR recent play.
|
||||
const userStats: { streak: number; isEligible: boolean }[] = [];
|
||||
for (const [, dates] of byUser) {
|
||||
// dates are already desc-sorted
|
||||
const dateSet = new Set(dates);
|
||||
|
||||
// Pick the most recent anchor: today if played, otherwise yesterday
|
||||
const anchor = dateSet.has(localDate) ? localDate : yesterdayStr;
|
||||
|
||||
let streak = 0;
|
||||
let cursor = new Date(`${anchor}T00:00:00`);
|
||||
|
||||
while (true) {
|
||||
const dateStr = cursor.toLocaleDateString('en-CA');
|
||||
if (!dateSet.has(dateStr)) break;
|
||||
streak++;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
const hasRecentPlay = dates.some((d) => d >= thirtyDaysAgoStr);
|
||||
userStats.push({ streak, isEligible: streak >= 1 || hasRecentPlay });
|
||||
}
|
||||
|
||||
const eligiblePlayers = userStats.filter((u) => u.isEligible);
|
||||
|
||||
if (eligiblePlayers.length === 0) {
|
||||
console.log('[streak-percentile] No eligible players found, returning 100th percentile');
|
||||
return json({ percentile: 100 });
|
||||
}
|
||||
|
||||
// Percentage of eligible players who have a streak >= targetStreak
|
||||
const atOrAbove = eligiblePlayers.filter((u) => u.streak >= targetStreak).length;
|
||||
const raw = (atOrAbove / eligiblePlayers.length) * 100;
|
||||
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
|
||||
|
||||
console.log('[streak-percentile]', {
|
||||
localDate,
|
||||
targetStreak,
|
||||
totalUsers: byUser.size,
|
||||
totalRows: rows.length,
|
||||
eligiblePlayers: eligiblePlayers.length,
|
||||
activeStreaks: userStats.filter((u) => u.streak >= 1).length,
|
||||
recentPlayers: userStats.filter((u) => u.isEligible).length,
|
||||
atOrAbove,
|
||||
raw,
|
||||
percentile,
|
||||
});
|
||||
|
||||
return json({ percentile });
|
||||
};
|
||||
36
src/routes/api/streak/+server.ts
Normal file
36
src/routes/api/streak/+server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const anonymousId = url.searchParams.get('anonymousId');
|
||||
const localDate = url.searchParams.get('localDate');
|
||||
|
||||
if (!anonymousId || !localDate) {
|
||||
error(400, 'Missing anonymousId or localDate');
|
||||
}
|
||||
|
||||
// Fetch all completion dates for this user, newest first
|
||||
const rows = await db
|
||||
.select({ date: dailyCompletions.date })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
const completedDates = new Set(rows.map((r) => r.date));
|
||||
|
||||
// Walk backwards from localDate, counting consecutive completed days
|
||||
let streak = 0;
|
||||
let cursor = new Date(`${localDate}T00:00:00`);
|
||||
|
||||
while (true) {
|
||||
const dateStr = cursor.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
if (!completedDates.has(dateStr)) break;
|
||||
streak++;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
return json({ streak });
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { and, eq, asc } from 'drizzle-orm';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const { anonymousId, date, guessCount } = await request.json();
|
||||
const { anonymousId, date, guessCount, guesses } = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
anonymousId,
|
||||
date,
|
||||
guessCount,
|
||||
guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
|
||||
completedAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
@@ -48,73 +49,23 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
|
||||
// Count ties: how many have the SAME guessCount (excluding self)
|
||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||
|
||||
// Average guesses
|
||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||
|
||||
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
||||
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error submitting completion:', err);
|
||||
return json({ error: 'Failed to submit completion' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const anonymousId = url.searchParams.get('anonymousId');
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
if (!anonymousId || !date) {
|
||||
return json({ error: 'Invalid data' }, { status: 400 });
|
||||
}
|
||||
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(and(
|
||||
eq(dailyCompletions.anonymousId, anonymousId),
|
||||
eq(dailyCompletions.date, date)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (userCompletions.length === 0) {
|
||||
return json({ error: 'No completion found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const userCompletion = userCompletions[0];
|
||||
const guessCount = userCompletion.guessCount;
|
||||
|
||||
// Calculate statistics
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, date))
|
||||
.orderBy(asc(dailyCompletions.completedAt));
|
||||
|
||||
const totalSolves = allCompletions.length;
|
||||
|
||||
// Solve rank: position in time-ordered list
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
|
||||
// Average guesses
|
||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats:', err);
|
||||
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
27
src/routes/auth/apple/+page.server.ts
Normal file
27
src/routes/auth/apple/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ cookies, request }) => {
|
||||
const data = await request.formData();
|
||||
const anonymousId = data.get('anonymousId')?.toString() || '';
|
||||
|
||||
// Generate CSRF state
|
||||
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
const state = encodeBase64url(stateBytes);
|
||||
|
||||
// Store state + anonymousId in a short-lived cookie
|
||||
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||
cookies.set('apple_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none',
|
||||
maxAge: 600
|
||||
});
|
||||
|
||||
redirect(302, getAppleAuthUrl(state));
|
||||
}
|
||||
};
|
||||
137
src/routes/auth/apple/callback/+server.ts
Normal file
137
src/routes/auth/apple/callback/+server.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { exchangeAppleCode, decodeAppleIdToken } from '$lib/server/apple-auth';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
import * as auth from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user as userTable } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const formData = await request.formData();
|
||||
const code = formData.get('code')?.toString();
|
||||
const state = formData.get('state')?.toString();
|
||||
// Apple sends user info as JSON string on FIRST authorization only
|
||||
const userInfoStr = formData.get('user')?.toString();
|
||||
|
||||
// Validate CSRF state
|
||||
const storedRaw = cookies.get('apple_oauth_state');
|
||||
if (!storedRaw || !state || !code) {
|
||||
throw error(400, 'Invalid OAuth callback');
|
||||
}
|
||||
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||
if (stored.state !== state) {
|
||||
throw error(400, 'State mismatch');
|
||||
}
|
||||
cookies.delete('apple_oauth_state', { path: '/' });
|
||||
|
||||
const anonId = stored.anonymousId;
|
||||
if (!anonId) {
|
||||
console.error('[Apple auth] Missing anonymousId in state cookie');
|
||||
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
|
||||
const claims = decodeAppleIdToken(tokens.id_token);
|
||||
const appleId = claims.sub;
|
||||
|
||||
// Parse user info (only present on first authorization)
|
||||
let appleFirstName: string | undefined;
|
||||
let appleLastName: string | undefined;
|
||||
if (userInfoStr) {
|
||||
try {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
appleFirstName = userInfo.name?.firstName;
|
||||
appleLastName = userInfo.name?.lastName;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
}
|
||||
|
||||
// --- User resolution ---
|
||||
let userId: string;
|
||||
|
||||
// 1. Check if a user with this appleId already exists (returning user)
|
||||
const existingAppleUser = await auth.getUserByAppleId(appleId);
|
||||
|
||||
if (existingAppleUser) {
|
||||
userId = existingAppleUser.id;
|
||||
console.log(`[Apple auth] Returning Apple user: userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else if (claims.email) {
|
||||
// 2. Check if email matches an existing email/password user
|
||||
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||
if (existingEmailUser) {
|
||||
// Link Apple account to existing user
|
||||
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||
userId = existingEmailUser.id;
|
||||
console.log(`[Apple auth] Linked Apple to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||
userId = anonId;
|
||||
console.log(`[Apple auth] New user (has email): userId=${userId}`);
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: claims.email,
|
||||
passwordHash: null,
|
||||
appleId,
|
||||
firstName: appleFirstName || null,
|
||||
lastName: appleLastName || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Handle race condition: if appleId was inserted between our check and insert
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByAppleId(appleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
console.log(`[Apple auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No email from Apple — create account with appleId only
|
||||
userId = anonId;
|
||||
console.log(`[Apple auth] New user (no email): userId=${userId}`);
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
appleId,
|
||||
firstName: appleFirstName || null,
|
||||
lastName: appleLastName || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByAppleId(appleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
console.log(`[Apple auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, userId);
|
||||
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||
|
||||
redirect(302, '/');
|
||||
};
|
||||
8
src/routes/auth/apple/test/+page.server.ts
Normal file
8
src/routes/auth/apple/test/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
40
src/routes/auth/apple/test/+page.svelte
Normal file
40
src/routes/auth/apple/test/+page.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { browser } from "$app/environment";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
|
||||
let isOpen = $state(true);
|
||||
const user = $derived(page.data.user);
|
||||
let anonymousId = $state("");
|
||||
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
anonymousId = localStorage.getItem("bibdle-anonymous-id") ?? "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
{#if user}
|
||||
<div class="text-white text-center space-y-4">
|
||||
<p class="text-lg">
|
||||
Signed in as <strong>{user.email ?? "no email"}</strong>
|
||||
</p>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button
|
||||
class="px-4 py-2 bg-red-600 rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (isOpen = true)}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Open Auth Modal
|
||||
</button>
|
||||
<AuthModal bind:isOpen {anonymousId} />
|
||||
{/if}
|
||||
</div>
|
||||
13
src/routes/auth/logout/+page.server.ts
Normal file
13
src/routes/auth/logout/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import * as auth from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ locals, cookies }) => {
|
||||
if (locals.session) {
|
||||
await auth.invalidateSession(locals.session.id);
|
||||
}
|
||||
auth.deleteSessionTokenCookie({ cookies });
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
53
src/routes/auth/signin/+page.server.ts
Normal file
53
src/routes/auth/signin/+page.server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import * as auth from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email')?.toString();
|
||||
const password = data.get('password')?.toString();
|
||||
const anonymousId = data.get('anonymousId')?.toString();
|
||||
|
||||
if (!email || !password) {
|
||||
return fail(400, { error: 'Email and password are required' });
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return fail(400, { error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user by email
|
||||
const user = await auth.getUserByEmail(email);
|
||||
if (!user || !user.passwordHash) {
|
||||
return fail(400, { error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await auth.verifyPassword(password, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
return fail(400, { error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
// Migrate anonymous stats if different anonymous ID
|
||||
await auth.migrateAnonymousStats(anonymousId, user.id);
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, user.id);
|
||||
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Sign in error:', error);
|
||||
return fail(500, { error: 'An error occurred during sign in' });
|
||||
}
|
||||
}
|
||||
};
|
||||
64
src/routes/auth/signup/+page.server.ts
Normal file
64
src/routes/auth/signup/+page.server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import * as auth from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email')?.toString();
|
||||
const password = data.get('password')?.toString();
|
||||
const firstName = data.get('firstName')?.toString();
|
||||
const lastName = data.get('lastName')?.toString();
|
||||
const anonymousId = data.get('anonymousId')?.toString();
|
||||
|
||||
if (!email || !password || !anonymousId) {
|
||||
return fail(400, { error: 'Email, password, and anonymous ID are required' });
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return fail(400, { error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existingUser = await auth.getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
return fail(400, { error: 'An account with this email already exists' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await auth.hashPassword(password);
|
||||
|
||||
// Create user with anonymousId as the user ID
|
||||
const user = await auth.createUser(
|
||||
anonymousId,
|
||||
email,
|
||||
passwordHash,
|
||||
firstName || undefined,
|
||||
lastName || undefined
|
||||
);
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, user.id);
|
||||
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Sign up error:', error);
|
||||
|
||||
// Check if it's a unique constraint error (user with this ID already exists)
|
||||
if (error instanceof Error && error.message.includes('UNIQUE constraint')) {
|
||||
return fail(400, { error: 'This account is already registered. Please sign in instead.' });
|
||||
}
|
||||
|
||||
return fail(500, { error: 'An error occurred during account creation' });
|
||||
}
|
||||
}
|
||||
};
|
||||
145
src/routes/feed.xml/+server.ts
Normal file
145
src/routes/feed.xml/+server.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses } from '$lib/server/db/schema';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
// Helper: Escape XML special characters
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Helper: Format YYYY-MM-DD to RFC 822 date string
|
||||
function formatRFC822(dateStr: string): string {
|
||||
// Parse date in America/New_York timezone (EST/EDT)
|
||||
// Assuming midnight ET
|
||||
const date = new Date(dateStr + 'T00:00:00-05:00');
|
||||
return date.toUTCString().replace('GMT', 'EST');
|
||||
}
|
||||
|
||||
// Helper: Format YYYY-MM-DD to readable date
|
||||
function formatReadableDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/New_York'
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
||||
function formatVerseText(text: string): string {
|
||||
let formatted = text;
|
||||
|
||||
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
||||
const pairs: [string, string][] = [
|
||||
['(', ')'],
|
||||
['[', ']'],
|
||||
['{', '}'],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
['\u201C', '\u201D'], // " "
|
||||
['\u2018', '\u2019'] // ' '
|
||||
];
|
||||
|
||||
// Check if text starts with opening punctuation without closing
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||
formatted += '...' + close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if text ends with closing punctuation without opening
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||
formatted = open + '...' + formatted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if text contains unbalanced opening quotes (not at start) without closing
|
||||
for (const [open, close] of pairs) {
|
||||
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
||||
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
||||
if (openCount > closeCount) {
|
||||
formatted += close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
||||
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||
|
||||
// Replace trailing punctuation with ellipsis
|
||||
// Preserve closing quotes/brackets that may have been added
|
||||
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
// Query last 30 verses, ordered by date descending
|
||||
const verses = await db
|
||||
.select()
|
||||
.from(dailyVerses)
|
||||
.orderBy(desc(dailyVerses.date))
|
||||
.limit(30);
|
||||
|
||||
// Generate ETag based on latest verse date
|
||||
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
|
||||
|
||||
// Check if client has cached version
|
||||
if (request.headers.get('If-None-Match') === etag) {
|
||||
return new Response(null, { status: 304 });
|
||||
}
|
||||
|
||||
// Get site URL from environment or use default
|
||||
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
|
||||
|
||||
// Build RSS XML
|
||||
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
|
||||
|
||||
const items = verses
|
||||
.map(
|
||||
(verse) => `
|
||||
<item>
|
||||
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
|
||||
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
|
||||
<link>${SITE_URL}</link>
|
||||
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
||||
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
||||
</item>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Bibdle</title>
|
||||
<link>${SITE_URL}</link>
|
||||
<description>A daily Bible game</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<ttl>720</ttl>${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
ETag: etag
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('RSS feed generation error:', error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -1,214 +1,17 @@
|
||||
<script lang="ts">
|
||||
let sentence = $state("");
|
||||
let results = $state<
|
||||
Array<{
|
||||
book: string;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
text: string;
|
||||
score: number;
|
||||
}>
|
||||
>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
async function searchVerses() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch("/api/similar-verses", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sentence, topK: 10 }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
results = data.results || [];
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
import Imposter from "$lib/components/Imposter.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1 class="title">Similar Verse Finder</h1>
|
||||
<svelte:head>
|
||||
<title>Bibdle (imposter mode)</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="search-section">
|
||||
<input
|
||||
bind:value={sentence}
|
||||
placeholder="Enter a sentence to find similar Bible verses..."
|
||||
class="input"
|
||||
/>
|
||||
<button onclick={searchVerses} disabled={loading} class="button">
|
||||
{loading ? "Searching..." : "Find Similar Verses"}
|
||||
</button>
|
||||
</div>
|
||||
<Container>
|
||||
<Container class="p-2 mt-12">
|
||||
<h1><i>Imposter Mode</i></h1>
|
||||
<p>Click the verse that doesn't belong</p>
|
||||
</Container>
|
||||
|
||||
{#if results.length > 0}
|
||||
<div class="results">
|
||||
{#each results as result, i (i)}
|
||||
<article class="result">
|
||||
<header>
|
||||
<strong>{result.book} {result.chapter}:{result.verse}</strong>
|
||||
<span class="score">Score: {result.score.toFixed(3)}</span>
|
||||
</header>
|
||||
<p>{result.text}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if sentence.trim() && !loading}
|
||||
<p class="no-results">No similar verses found. Try another sentence!</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 0.75rem;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
color: #2c3e50;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 12px;
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.2s ease;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background: #a0aec0;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.result {
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.result:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.result header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.result strong {
|
||||
font-size: 1.3rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 1rem;
|
||||
color: #718096;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result p {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: #4a5568;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 1.75rem 0.75rem;
|
||||
color: #a0aec0;
|
||||
font-size: 1.2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.result header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<Imposter />
|
||||
</Container>
|
||||
|
||||
@@ -10,10 +10,54 @@ html, body {
|
||||
background: oklch(89.126% 0.06134 298.626);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
background: oklch(18% 0.03 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
.big-text {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: rgb(107 114 128);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.big-text {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
}
|
||||
|
||||
/* Page load animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.8s ease-out both;
|
||||
}
|
||||
|
||||
.animate-delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.animate-delay-400 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.animate-delay-600 {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.animate-delay-800 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
214
src/routes/similarity/+page.svelte
Normal file
214
src/routes/similarity/+page.svelte
Normal file
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
let sentence = $state("");
|
||||
let results = $state<
|
||||
Array<{
|
||||
book: string;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
text: string;
|
||||
score: number;
|
||||
}>
|
||||
>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
async function searchVerses() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch("/api/similar-verses", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sentence, topK: 10 }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
results = data.results || [];
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1 class="title">Similar Verse Finder</h1>
|
||||
|
||||
<div class="search-section">
|
||||
<input
|
||||
bind:value={sentence}
|
||||
placeholder="Enter a sentence to find similar Bible verses..."
|
||||
class="input"
|
||||
/>
|
||||
<button onclick={searchVerses} disabled={loading} class="button">
|
||||
{loading ? "Searching..." : "Find Similar Verses"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if results.length > 0}
|
||||
<div class="results">
|
||||
{#each results as result, i (i)}
|
||||
<article class="result">
|
||||
<header>
|
||||
<strong>{result.book} {result.chapter}:{result.verse}</strong>
|
||||
<span class="score">Score: {result.score.toFixed(3)}</span>
|
||||
</header>
|
||||
<p>{result.text}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if sentence.trim() && !loading}
|
||||
<p class="no-results">No similar verses found. Try another sentence!</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 0.75rem;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
color: #2c3e50;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 12px;
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.2s ease;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
background: #a0aec0;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.result {
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.result:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.result header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.result strong {
|
||||
font-size: 1.3rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 1rem;
|
||||
color: #718096;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result p {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: #4a5568;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 1.75rem 0.75rem;
|
||||
color: #a0aec0;
|
||||
font-size: 1.2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.result header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
256
src/routes/stats/+page.server.ts
Normal file
256
src/routes/stats/+page.server.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions, dailyVerses, type DailyCompletion } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
// Check if user is authenticated
|
||||
if (!locals.user) {
|
||||
return {
|
||||
stats: null,
|
||||
error: null,
|
||||
user: null,
|
||||
session: null,
|
||||
requiresAuth: true
|
||||
};
|
||||
}
|
||||
|
||||
const userId = locals.user.id;
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
stats: null,
|
||||
error: 'No user ID provided',
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
|
||||
// Get user's current date from timezone query param
|
||||
const timezone = url.searchParams.get('tz') || 'UTC';
|
||||
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
|
||||
|
||||
try {
|
||||
// Get all completions for this user
|
||||
const completions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId))
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
if (completions.length === 0) {
|
||||
return {
|
||||
stats: {
|
||||
totalSolves: 0,
|
||||
avgGuesses: 0,
|
||||
gradeDistribution: {
|
||||
'S++': 0,
|
||||
'S+': 0,
|
||||
'A+': 0,
|
||||
'A': 0,
|
||||
'B+': 0,
|
||||
'B': 0,
|
||||
'C+': 0,
|
||||
'C': 0
|
||||
},
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
recentCompletions: [],
|
||||
worstDay: null,
|
||||
bestBook: null,
|
||||
mostSeenBook: null,
|
||||
totalBooksSeenOT: 0,
|
||||
totalBooksSeenNT: 0
|
||||
},
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate basic stats
|
||||
const totalSolves = completions.length;
|
||||
const totalGuesses = completions.reduce((sum: number, c: DailyCompletion) => sum + c.guessCount, 0);
|
||||
const avgGuesses = Math.round((totalGuesses / totalSolves) * 100) / 100;
|
||||
|
||||
// Calculate grade distribution
|
||||
const gradeDistribution = {
|
||||
'S++': 0, // This will be calculated differently since we don't store chapter correctness
|
||||
'S+': completions.filter((c: DailyCompletion) => c.guessCount === 1).length,
|
||||
'A+': completions.filter((c: DailyCompletion) => c.guessCount === 2).length,
|
||||
'A': completions.filter((c: DailyCompletion) => c.guessCount === 3).length,
|
||||
'B+': completions.filter((c: DailyCompletion) => c.guessCount >= 4 && c.guessCount <= 6).length,
|
||||
'B': completions.filter((c: DailyCompletion) => c.guessCount >= 7 && c.guessCount <= 10).length,
|
||||
'C+': completions.filter((c: DailyCompletion) => c.guessCount >= 11 && c.guessCount <= 15).length,
|
||||
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
||||
};
|
||||
|
||||
// Calculate streaks
|
||||
const sortedDates = completions
|
||||
.map((c: DailyCompletion) => c.date)
|
||||
.sort();
|
||||
|
||||
let currentStreak = 0;
|
||||
let bestStreak = 0;
|
||||
let tempStreak = 1;
|
||||
|
||||
if (sortedDates.length > 0) {
|
||||
// Check if current streak is active (includes today or yesterday)
|
||||
// Use the user's local date passed from the client
|
||||
const today = userToday;
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
||||
currentStreak = 1;
|
||||
|
||||
// Count backwards from the most recent date
|
||||
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||
const currentDate = new Date(sortedDates[i + 1]);
|
||||
const prevDate = new Date(sortedDates[i]);
|
||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff === 1) {
|
||||
currentStreak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate best streak
|
||||
bestStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff === 1) {
|
||||
tempStreak++;
|
||||
} else {
|
||||
bestStreak = Math.max(bestStreak, tempStreak);
|
||||
tempStreak = 1;
|
||||
}
|
||||
}
|
||||
bestStreak = Math.max(bestStreak, tempStreak);
|
||||
}
|
||||
|
||||
// Get recent completions (last 7 days)
|
||||
const recentCompletions = completions
|
||||
.slice(0, 7)
|
||||
.map((c: DailyCompletion) => ({
|
||||
date: c.date,
|
||||
guessCount: c.guessCount,
|
||||
grade: getGradeFromGuesses(c.guessCount)
|
||||
}));
|
||||
|
||||
// Calculate worst day (highest guess count)
|
||||
const worstDay = completions.reduce((max, c) =>
|
||||
c.guessCount > max.guessCount ? c : max,
|
||||
completions[0]
|
||||
);
|
||||
|
||||
// Get all daily verses to link completions to books
|
||||
const allVerses = await db
|
||||
.select()
|
||||
.from(dailyVerses);
|
||||
|
||||
// Create a map of date -> bookId
|
||||
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
|
||||
|
||||
// Calculate book-specific stats
|
||||
const bookStats = new Map<string, { count: number; totalGuesses: number }>();
|
||||
|
||||
for (const completion of completions) {
|
||||
const bookId = dateToBookId.get(completion.date);
|
||||
if (bookId) {
|
||||
const existing = bookStats.get(bookId) || { count: 0, totalGuesses: 0 };
|
||||
bookStats.set(bookId, {
|
||||
count: existing.count + 1,
|
||||
totalGuesses: existing.totalGuesses + completion.guessCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find book you know the best (lowest avg guesses)
|
||||
let bestBook: { bookId: string; avgGuesses: number; count: number } | null = null;
|
||||
for (const [bookId, stats] of bookStats.entries()) {
|
||||
const avgGuesses = stats.totalGuesses / stats.count;
|
||||
if (!bestBook || avgGuesses < bestBook.avgGuesses) {
|
||||
bestBook = { bookId, avgGuesses, count: stats.count };
|
||||
}
|
||||
}
|
||||
|
||||
// Find most seen book
|
||||
let mostSeenBook: { bookId: string; count: number } | null = null;
|
||||
for (const [bookId, stats] of bookStats.entries()) {
|
||||
if (!mostSeenBook || stats.count > mostSeenBook.count) {
|
||||
mostSeenBook = { bookId, count: stats.count };
|
||||
}
|
||||
}
|
||||
|
||||
// Count unique books by testament
|
||||
const oldTestamentBooks = new Set<string>();
|
||||
const newTestamentBooks = new Set<string>();
|
||||
|
||||
for (const [bookId, _] of bookStats.entries()) {
|
||||
const book = bibleBooks.find(b => b.id === bookId);
|
||||
if (book) {
|
||||
if (book.testament === 'old') {
|
||||
oldTestamentBooks.add(bookId);
|
||||
} else {
|
||||
newTestamentBooks.add(bookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalSolves,
|
||||
avgGuesses,
|
||||
gradeDistribution,
|
||||
currentStreak,
|
||||
bestStreak,
|
||||
recentCompletions,
|
||||
worstDay: {
|
||||
date: worstDay.date,
|
||||
guessCount: worstDay.guessCount
|
||||
},
|
||||
bestBook: bestBook ? {
|
||||
bookId: bestBook.bookId,
|
||||
avgGuesses: Math.round(bestBook.avgGuesses * 100) / 100,
|
||||
count: bestBook.count
|
||||
} : null,
|
||||
mostSeenBook: mostSeenBook ? {
|
||||
bookId: mostSeenBook.bookId,
|
||||
count: mostSeenBook.count
|
||||
} : null,
|
||||
totalBooksSeenOT: oldTestamentBooks.size,
|
||||
totalBooksSeenNT: newTestamentBooks.size
|
||||
},
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error);
|
||||
return {
|
||||
stats: null,
|
||||
error: 'Failed to fetch stats',
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function getGradeFromGuesses(guessCount: number): string {
|
||||
if (guessCount === 1) return "S+";
|
||||
if (guessCount === 2) return "A+";
|
||||
if (guessCount === 3) return "A";
|
||||
if (guessCount >= 4 && guessCount <= 6) return "B+";
|
||||
if (guessCount >= 7 && guessCount <= 10) return "B";
|
||||
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
||||
return "C";
|
||||
}
|
||||
417
src/routes/stats/+page.svelte
Normal file
417
src/routes/stats/+page.svelte
Normal file
@@ -0,0 +1,417 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { enhance } from "$app/forms";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import {
|
||||
getGradeColor,
|
||||
formatDate,
|
||||
getStreakMessage,
|
||||
getPerformanceMessage,
|
||||
type UserStats,
|
||||
} from "$lib/utils/stats";
|
||||
|
||||
interface PageData {
|
||||
stats: UserStats | null;
|
||||
error?: string;
|
||||
user?: any;
|
||||
session?: any;
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let authModalOpen = $state(false);
|
||||
let anonymousId = $state("");
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
const key = "bibdle-anonymous-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function getGradePercentage(count: number, total: number): number {
|
||||
return total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
}
|
||||
|
||||
function getBookName(bookId: string): string {
|
||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||
}
|
||||
|
||||
$inspect(data);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Stats | Bibdle</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="View your Bibdle game statistics and performance"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||
Your Stats
|
||||
</h1>
|
||||
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||
Track your Bibdle performance over time
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="mt-4 text-gray-300">Loading your stats...</p>
|
||||
</div>
|
||||
{:else if data.requiresAuth}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<h2 class="text-2xl font-bold text-blue-200 mb-4">
|
||||
Authentication Required
|
||||
</h2>
|
||||
<p class="text-blue-300 mb-6">
|
||||
You must be logged in to see your stats.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={() => (authModalOpen = true)}
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
🔐 Sign In / Sign Up
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.error}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<p class="text-red-300">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Return to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !data.stats}
|
||||
<div class="text-center py-12">
|
||||
<Container class="p-8 max-w-md mx-auto">
|
||||
<div class="text-yellow-400 mb-4 text-lg">
|
||||
No stats available yet.
|
||||
</div>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Start playing to build your stats!
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
||||
>
|
||||
Start Playing
|
||||
</a>
|
||||
</Container>
|
||||
</div>
|
||||
{:else}
|
||||
{@const stats = data.stats}
|
||||
|
||||
<!-- Key Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
||||
<!-- Current Streak -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">🔥</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||
>
|
||||
{stats.currentStreak}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Current Streak
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Longest Streak -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">⭐</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-purple-400 mb-1"
|
||||
>
|
||||
{stats.bestStreak}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Best Streak
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Average Guesses -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">🎯</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-blue-400 mb-1"
|
||||
>
|
||||
{stats.avgGuesses}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Avg Guesses
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Total Solves -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">✅</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-green-400 mb-1"
|
||||
>
|
||||
{stats.totalSolves}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Total Solves
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{#if stats.totalSolves > 0}
|
||||
<!-- Book Stats Grid -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6"
|
||||
>
|
||||
<!-- Worst Day -->
|
||||
{#if stats.worstDay}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">😅</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Worst Day
|
||||
</div>
|
||||
<div
|
||||
class="text-xl md:text-2xl font-bold text-red-400 truncate"
|
||||
>
|
||||
{stats.worstDay.guessCount} guesses
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{formatDate(stats.worstDay.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- Best Book -->
|
||||
{#if stats.bestBook}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">🏆</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Best Book
|
||||
</div>
|
||||
<div
|
||||
class="text-lg md:text-xl font-bold text-amber-400 truncate"
|
||||
>
|
||||
{getBookName(stats.bestBook.bookId)}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{stats.bestBook.avgGuesses} avg guesses ({stats
|
||||
.bestBook.count}x)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- Most Seen Book -->
|
||||
{#if stats.mostSeenBook}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">📖</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Most Seen Book
|
||||
</div>
|
||||
<div
|
||||
class="text-lg md:text-xl font-bold text-indigo-400 truncate"
|
||||
>
|
||||
{getBookName(stats.mostSeenBook.bookId)}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{stats.mostSeenBook.count} time{stats
|
||||
.mostSeenBook.count === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- Total Books Seen -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">📚</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Unique Books
|
||||
</div>
|
||||
<div
|
||||
class="text-xl md:text-2xl font-bold text-teal-400"
|
||||
>
|
||||
{stats.totalBooksSeenOT +
|
||||
stats.totalBooksSeenNT}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">
|
||||
OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<!-- Grade Distribution -->
|
||||
<Container class="p-5 md:p-6 mb-6">
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
|
||||
Grade Distribution
|
||||
</h2>
|
||||
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
|
||||
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
|
||||
{@const percentage = getGradePercentage(
|
||||
count,
|
||||
stats.totalSolves,
|
||||
)}
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<span
|
||||
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
|
||||
grade,
|
||||
)}"
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-lg md:text-2xl font-bold text-gray-100"
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Recent Performance -->
|
||||
{#if stats.recentCompletions.length > 0}
|
||||
<Container class="p-5 md:p-6">
|
||||
<h2
|
||||
class="text-lg md:text-xl font-bold text-gray-100 mb-4"
|
||||
>
|
||||
Recent Performance
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-sm md:text-base font-medium text-gray-200"
|
||||
>{formatDate(completion.date)}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 md:gap-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs md:text-sm text-gray-300"
|
||||
>{completion.guessCount} guess{completion.guessCount ===
|
||||
1
|
||||
? ""
|
||||
: "es"}</span
|
||||
>
|
||||
<span
|
||||
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
|
||||
completion.grade,
|
||||
)}"
|
||||
>
|
||||
{completion.grade}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@@ -7,7 +7,13 @@ const config = {
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: { adapter: adapter() }
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
csrf: {
|
||||
// Allow Apple Sign In cross-origin form_post callback
|
||||
trustedOrigins: ['https://appleid.apple.com']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
245
tests/signin-migration-unit.test.ts
Normal file
245
tests/signin-migration-unit.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
|
||||
describe('Signin Migration Logic (Unit Tests)', () => {
|
||||
|
||||
// Test the deduplication algorithm independently
|
||||
it('should correctly identify and remove duplicates keeping earliest', () => {
|
||||
// Mock completion data structure
|
||||
type MockCompletion = {
|
||||
id: string;
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
guessCount: number;
|
||||
completedAt: Date;
|
||||
};
|
||||
|
||||
// Test data: multiple completions on same date
|
||||
const allUserCompletions: MockCompletion[] = [
|
||||
{
|
||||
id: 'comp1',
|
||||
anonymousId: 'user123',
|
||||
date: '2024-01-01',
|
||||
guessCount: 4,
|
||||
completedAt: new Date('2024-01-01T08:00:00Z') // Earliest
|
||||
},
|
||||
{
|
||||
id: 'comp2',
|
||||
anonymousId: 'user123',
|
||||
date: '2024-01-01',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-01T14:00:00Z') // Later
|
||||
},
|
||||
{
|
||||
id: 'comp3',
|
||||
anonymousId: 'user123',
|
||||
date: '2024-01-01',
|
||||
guessCount: 6,
|
||||
completedAt: new Date('2024-01-01T20:00:00Z') // Latest
|
||||
},
|
||||
{
|
||||
id: 'comp4',
|
||||
anonymousId: 'user123',
|
||||
date: '2024-01-02',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-02T09:00:00Z') // Unique date
|
||||
}
|
||||
];
|
||||
|
||||
// Implement the deduplication logic from signin server action
|
||||
const dateGroups = new Map<string, MockCompletion[]>();
|
||||
for (const completion of allUserCompletions) {
|
||||
const date = completion.date;
|
||||
if (!dateGroups.has(date)) {
|
||||
dateGroups.set(date, []);
|
||||
}
|
||||
dateGroups.get(date)!.push(completion);
|
||||
}
|
||||
|
||||
// Process dates with duplicates
|
||||
const duplicateIds: string[] = [];
|
||||
const keptEntries: MockCompletion[] = [];
|
||||
|
||||
for (const [date, completions] of dateGroups) {
|
||||
if (completions.length > 1) {
|
||||
// Sort by completedAt timestamp (earliest first)
|
||||
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||
|
||||
// Keep the first (earliest), mark the rest for deletion
|
||||
const toKeep = completions[0];
|
||||
const toDelete = completions.slice(1);
|
||||
|
||||
keptEntries.push(toKeep);
|
||||
duplicateIds.push(...toDelete.map(c => c.id));
|
||||
} else {
|
||||
// Single entry for this date, keep it
|
||||
keptEntries.push(completions[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the logic worked correctly
|
||||
expect(duplicateIds).toHaveLength(2); // comp2 and comp3 should be deleted
|
||||
expect(duplicateIds).toContain('comp2');
|
||||
expect(duplicateIds).toContain('comp3');
|
||||
expect(duplicateIds).not.toContain('comp1'); // comp1 should be kept (earliest)
|
||||
expect(duplicateIds).not.toContain('comp4'); // comp4 should be kept (unique date)
|
||||
|
||||
// Verify kept entries
|
||||
expect(keptEntries).toHaveLength(2);
|
||||
|
||||
// Check that the earliest entry for 2024-01-01 was kept
|
||||
const jan1Entry = keptEntries.find(e => e.date === '2024-01-01');
|
||||
expect(jan1Entry).toBeTruthy();
|
||||
expect(jan1Entry!.id).toBe('comp1'); // Earliest timestamp
|
||||
expect(jan1Entry!.guessCount).toBe(4);
|
||||
expect(jan1Entry!.completedAt.getTime()).toBe(new Date('2024-01-01T08:00:00Z').getTime());
|
||||
|
||||
// Check that unique date entry was preserved
|
||||
const jan2Entry = keptEntries.find(e => e.date === '2024-01-02');
|
||||
expect(jan2Entry).toBeTruthy();
|
||||
expect(jan2Entry!.id).toBe('comp4');
|
||||
});
|
||||
|
||||
it('should handle no duplicates correctly', () => {
|
||||
type MockCompletion = {
|
||||
id: string;
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
guessCount: number;
|
||||
completedAt: Date;
|
||||
};
|
||||
|
||||
// Test data: all unique dates
|
||||
const allUserCompletions: MockCompletion[] = [
|
||||
{
|
||||
id: 'comp1',
|
||||
anonymousId: 'user123',
|
||||
date: '2024-01-01',
|
||||
guessCount: 4,
|
||||
completedAt: new Date('2024-01-01T08:00:00Z')
|
||||
},
|
||||
{
|
||||
id: 'comp2',
|
||||
anonymousId: 'user123',
|
||||
date: '2024-01-02',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-02T14:00:00Z')
|
||||
}
|
||||
];
|
||||
|
||||
// Run deduplication logic
|
||||
const dateGroups = new Map<string, MockCompletion[]>();
|
||||
for (const completion of allUserCompletions) {
|
||||
if (!dateGroups.has(completion.date)) {
|
||||
dateGroups.set(completion.date, []);
|
||||
}
|
||||
dateGroups.get(completion.date)!.push(completion);
|
||||
}
|
||||
|
||||
const duplicateIds: string[] = [];
|
||||
for (const [date, completions] of dateGroups) {
|
||||
if (completions.length > 1) {
|
||||
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||
const toDelete = completions.slice(1);
|
||||
duplicateIds.push(...toDelete.map(c => c.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Should find no duplicates
|
||||
expect(duplicateIds).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle edge case with same timestamp', () => {
|
||||
type MockCompletion = {
|
||||
id: string;
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
guessCount: number;
|
||||
completedAt: Date;
|
||||
};
|
||||
|
||||
// Edge case: same completion time (very unlikely but possible)
|
||||
const sameTime = new Date('2024-01-01T08:00:00Z');
|
||||
const allUserCompletions: MockCompletion[] = [
|
||||
{
|
||||
id: 'comp1',
|
||||
anonymousId: 'user123',
|
||||
date: '2024-01-01',
|
||||
guessCount: 3,
|
||||
completedAt: sameTime
|
||||
},
|
||||
{
|
||||
id: 'comp2',
|
||||
anonymousId: 'user123',
|
||||
date: '2024-01-01',
|
||||
guessCount: 5,
|
||||
completedAt: sameTime
|
||||
}
|
||||
];
|
||||
|
||||
// Run deduplication logic
|
||||
const dateGroups = new Map<string, MockCompletion[]>();
|
||||
for (const completion of allUserCompletions) {
|
||||
if (!dateGroups.has(completion.date)) {
|
||||
dateGroups.set(completion.date, []);
|
||||
}
|
||||
dateGroups.get(completion.date)!.push(completion);
|
||||
}
|
||||
|
||||
const duplicateIds: string[] = [];
|
||||
for (const [date, completions] of dateGroups) {
|
||||
if (completions.length > 1) {
|
||||
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||
const toDelete = completions.slice(1);
|
||||
duplicateIds.push(...toDelete.map(c => c.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Should still remove one duplicate (deterministically based on array order)
|
||||
expect(duplicateIds).toHaveLength(1);
|
||||
// Since they have the same timestamp, it keeps the first one in the sorted array
|
||||
expect(duplicateIds[0]).toBe('comp2'); // Second entry gets removed
|
||||
});
|
||||
|
||||
it('should validate migration condition logic', () => {
|
||||
// Test the condition check that determines when migration should occur
|
||||
const testCases = [
|
||||
{
|
||||
anonymousId: 'device2-id',
|
||||
userId: 'device1-id',
|
||||
shouldMigrate: true,
|
||||
description: 'Different IDs should trigger migration'
|
||||
},
|
||||
{
|
||||
anonymousId: 'same-id',
|
||||
userId: 'same-id',
|
||||
shouldMigrate: false,
|
||||
description: 'Same IDs should not trigger migration'
|
||||
},
|
||||
{
|
||||
anonymousId: null as any,
|
||||
userId: 'user-id',
|
||||
shouldMigrate: false,
|
||||
description: 'Null anonymous ID should not trigger migration'
|
||||
},
|
||||
{
|
||||
anonymousId: undefined as any,
|
||||
userId: 'user-id',
|
||||
shouldMigrate: false,
|
||||
description: 'Undefined anonymous ID should not trigger migration'
|
||||
},
|
||||
{
|
||||
anonymousId: '',
|
||||
userId: 'user-id',
|
||||
shouldMigrate: false,
|
||||
description: 'Empty anonymous ID should not trigger migration'
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
// This is the exact condition from signin/+page.server.ts
|
||||
const shouldMigrate = !!(testCase.anonymousId && testCase.anonymousId !== testCase.userId);
|
||||
|
||||
expect(shouldMigrate).toBe(testCase.shouldMigrate);
|
||||
}
|
||||
});
|
||||
});
|
||||
287
tests/signin-migration.test.ts
Normal file
287
tests/signin-migration.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { testDb as db } from '../src/lib/server/db/test';
|
||||
import { user, session, dailyCompletions } from '../src/lib/server/db/schema';
|
||||
import * as auth from '../src/lib/server/auth.test';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Test helper functions
|
||||
function generateTestUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
async function createTestUser(anonymousId: string, email: string, password: string = 'testpass123') {
|
||||
const passwordHash = await auth.hashPassword(password);
|
||||
const testUser = await auth.createUser(anonymousId, email, passwordHash, 'Test', 'User');
|
||||
return testUser;
|
||||
}
|
||||
|
||||
async function createTestCompletion(anonymousId: string, date: string, guessCount: number, completedAt: Date) {
|
||||
const completion = {
|
||||
id: generateTestUUID(),
|
||||
anonymousId,
|
||||
date,
|
||||
guessCount,
|
||||
completedAt
|
||||
};
|
||||
await db.insert(dailyCompletions).values(completion);
|
||||
return completion;
|
||||
}
|
||||
|
||||
async function clearTestData() {
|
||||
// Clear test data in reverse dependency order
|
||||
await db.delete(session);
|
||||
await db.delete(dailyCompletions);
|
||||
await db.delete(user);
|
||||
}
|
||||
|
||||
describe('Signin Stats Migration', () => {
|
||||
beforeEach(async () => {
|
||||
await clearTestData();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearTestData();
|
||||
});
|
||||
|
||||
it('should migrate stats from local anonymous ID to user ID on signin', async () => {
|
||||
// Setup: Create user with device 1 anonymous ID
|
||||
const device1AnonymousId = generateTestUUID();
|
||||
const device2AnonymousId = generateTestUUID();
|
||||
const email = 'test@example.com';
|
||||
|
||||
const testUser = await createTestUser(device1AnonymousId, email);
|
||||
|
||||
// Add some completions for device 1 (user's original device)
|
||||
await createTestCompletion(device1AnonymousId, '2024-01-01', 3, new Date('2024-01-01T08:00:00Z'));
|
||||
await createTestCompletion(device1AnonymousId, '2024-01-02', 5, new Date('2024-01-02T09:00:00Z'));
|
||||
|
||||
// Add some completions for device 2 (before signin)
|
||||
await createTestCompletion(device2AnonymousId, '2024-01-03', 2, new Date('2024-01-03T10:00:00Z'));
|
||||
await createTestCompletion(device2AnonymousId, '2024-01-04', 4, new Date('2024-01-04T11:00:00Z'));
|
||||
|
||||
// Verify initial state
|
||||
const initialDevice1Stats = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, device1AnonymousId));
|
||||
const initialDevice2Stats = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||
|
||||
expect(initialDevice1Stats).toHaveLength(2);
|
||||
expect(initialDevice2Stats).toHaveLength(2);
|
||||
|
||||
// Simulate signin action - this is what happens in signin/+page.server.ts
|
||||
const user = await auth.getUserByEmail(email);
|
||||
expect(user).toBeTruthy();
|
||||
|
||||
// Migrate stats (simulating the signin logic)
|
||||
if (device2AnonymousId && device2AnonymousId !== user!.id) {
|
||||
// Update all daily completions from device2 anonymous ID to user's ID
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: user!.id })
|
||||
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||
}
|
||||
|
||||
// Verify migration worked
|
||||
const finalUserStats = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||
const remainingDevice2Stats = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||
|
||||
expect(finalUserStats).toHaveLength(4); // All 4 completions now under user ID
|
||||
expect(remainingDevice2Stats).toHaveLength(0); // No more completions under device2 ID
|
||||
|
||||
// Verify the actual data is correct
|
||||
const dates = finalUserStats.map(c => c.date).sort();
|
||||
expect(dates).toEqual(['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04']);
|
||||
});
|
||||
|
||||
it('should deduplicate entries for same date keeping earliest completion', async () => {
|
||||
// Setup: User played same day on both devices
|
||||
const device1AnonymousId = generateTestUUID();
|
||||
const device2AnonymousId = generateTestUUID();
|
||||
const email = 'test@example.com';
|
||||
|
||||
const testUser = await createTestUser(device1AnonymousId, email);
|
||||
|
||||
// Both devices played on same date - device1 played earlier and better
|
||||
const date = '2024-01-01';
|
||||
const earlierTime = new Date('2024-01-01T08:00:00Z');
|
||||
const laterTime = new Date('2024-01-01T14:00:00Z');
|
||||
|
||||
await createTestCompletion(device1AnonymousId, date, 3, earlierTime); // Better score, earlier
|
||||
await createTestCompletion(device2AnonymousId, date, 5, laterTime); // Worse score, later
|
||||
|
||||
// Also add unique dates to ensure they're preserved
|
||||
await createTestCompletion(device1AnonymousId, '2024-01-02', 4, new Date('2024-01-02T09:00:00Z'));
|
||||
await createTestCompletion(device2AnonymousId, '2024-01-03', 2, new Date('2024-01-03T10:00:00Z'));
|
||||
|
||||
// Migrate stats
|
||||
const user = await auth.getUserByEmail(email);
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: user!.id })
|
||||
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||
|
||||
// Implement deduplication logic (from signin server action)
|
||||
const allUserCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||
|
||||
// Group by date to find duplicates
|
||||
const dateGroups = new Map<string, typeof allUserCompletions>();
|
||||
for (const completion of allUserCompletions) {
|
||||
const date = completion.date;
|
||||
if (!dateGroups.has(date)) {
|
||||
dateGroups.set(date, []);
|
||||
}
|
||||
dateGroups.get(date)!.push(completion);
|
||||
}
|
||||
|
||||
// Process dates with duplicates
|
||||
const duplicateIds: string[] = [];
|
||||
for (const [date, completions] of dateGroups) {
|
||||
if (completions.length > 1) {
|
||||
// Sort by completedAt timestamp (earliest first)
|
||||
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||
|
||||
// Keep the first (earliest), mark the rest for deletion
|
||||
const toDelete = completions.slice(1);
|
||||
duplicateIds.push(...toDelete.map(c => c.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete duplicate entries
|
||||
if (duplicateIds.length > 0) {
|
||||
await db
|
||||
.delete(dailyCompletions)
|
||||
.where(inArray(dailyCompletions.id, duplicateIds));
|
||||
}
|
||||
|
||||
// Verify deduplication worked correctly
|
||||
const finalStats = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||
|
||||
expect(finalStats).toHaveLength(3); // One duplicate removed
|
||||
|
||||
// Verify the correct entry was kept for the duplicate date
|
||||
const duplicateDateEntry = finalStats.find(c => c.date === date);
|
||||
expect(duplicateDateEntry).toBeTruthy();
|
||||
expect(duplicateDateEntry!.guessCount).toBe(3); // Better score kept
|
||||
expect(duplicateDateEntry!.completedAt.getTime()).toBe(earlierTime.getTime()); // Earlier time kept
|
||||
|
||||
// Verify unique dates are preserved
|
||||
const allDates = finalStats.map(c => c.date).sort();
|
||||
expect(allDates).toEqual(['2024-01-01', '2024-01-02', '2024-01-03']);
|
||||
});
|
||||
|
||||
it('should handle no migration when anonymous ID matches user ID', async () => {
|
||||
// Setup: User signing in from same device they signed up on
|
||||
const anonymousId = generateTestUUID();
|
||||
const email = 'test@example.com';
|
||||
|
||||
const testUser = await createTestUser(anonymousId, email);
|
||||
|
||||
// Add some completions
|
||||
await createTestCompletion(anonymousId, '2024-01-01', 3, new Date('2024-01-01T08:00:00Z'));
|
||||
await createTestCompletion(anonymousId, '2024-01-02', 5, new Date('2024-01-02T09:00:00Z'));
|
||||
|
||||
// Verify initial state
|
||||
const initialStats = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||
expect(initialStats).toHaveLength(2);
|
||||
|
||||
// Simulate signin with same anonymous ID (no migration needed)
|
||||
const user = await auth.getUserByEmail(email);
|
||||
|
||||
// Migration logic should skip when IDs match
|
||||
const shouldMigrate = anonymousId && anonymousId !== user!.id;
|
||||
expect(shouldMigrate).toBe(false);
|
||||
|
||||
// Verify no changes
|
||||
const finalStats = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||
expect(finalStats).toHaveLength(2);
|
||||
expect(finalStats[0].anonymousId).toBe(anonymousId);
|
||||
});
|
||||
|
||||
it('should handle multiple duplicates for same date correctly', async () => {
|
||||
// Edge case: User played same date on 3+ devices
|
||||
const device1AnonymousId = generateTestUUID();
|
||||
const device2AnonymousId = generateTestUUID();
|
||||
const device3AnonymousId = generateTestUUID();
|
||||
const email = 'test@example.com';
|
||||
|
||||
const testUser = await createTestUser(device1AnonymousId, email);
|
||||
|
||||
const date = '2024-01-01';
|
||||
// Three completions on same date at different times
|
||||
await createTestCompletion(device1AnonymousId, date, 4, new Date('2024-01-01T08:00:00Z')); // Earliest
|
||||
await createTestCompletion(device2AnonymousId, date, 2, new Date('2024-01-01T14:00:00Z')); // Middle
|
||||
await createTestCompletion(device3AnonymousId, date, 6, new Date('2024-01-01T20:00:00Z')); // Latest
|
||||
|
||||
// Migrate all to user ID
|
||||
const user = await auth.getUserByEmail(email);
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: user!.id })
|
||||
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: user!.id })
|
||||
.where(eq(dailyCompletions.anonymousId, device3AnonymousId));
|
||||
|
||||
// Implement deduplication
|
||||
const allUserCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||
|
||||
const dateGroups = new Map<string, typeof allUserCompletions>();
|
||||
for (const completion of allUserCompletions) {
|
||||
if (!dateGroups.has(completion.date)) {
|
||||
dateGroups.set(completion.date, []);
|
||||
}
|
||||
dateGroups.get(completion.date)!.push(completion);
|
||||
}
|
||||
|
||||
const duplicateIds: string[] = [];
|
||||
for (const [_, completions] of dateGroups) {
|
||||
if (completions.length > 1) {
|
||||
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||
const toDelete = completions.slice(1);
|
||||
duplicateIds.push(...toDelete.map(c => c.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete duplicates
|
||||
for (const id of duplicateIds) {
|
||||
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, id));
|
||||
}
|
||||
|
||||
// Verify only earliest kept
|
||||
const finalStats = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||
|
||||
expect(finalStats).toHaveLength(1); // 2 duplicates removed
|
||||
expect(finalStats[0].guessCount).toBe(4); // First device's score
|
||||
expect(finalStats[0].completedAt.getTime()).toBe(new Date('2024-01-01T08:00:00Z').getTime());
|
||||
});
|
||||
});
|
||||
498
tests/timezone-handling.test.ts
Normal file
498
tests/timezone-handling.test.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
||||
import { testDb as db } from '$lib/server/db/test';
|
||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
describe('Timezone-aware daily verse system', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up test data before each test
|
||||
await db.delete(dailyVerses);
|
||||
await db.delete(dailyCompletions);
|
||||
});
|
||||
|
||||
describe('Daily verse retrieval', () => {
|
||||
test('users in different timezones can see different verses at the same UTC moment', async () => {
|
||||
// Simulate: It's 2024-01-15 23:00 UTC
|
||||
// - Tokyo (UTC+9): 2024-01-16 08:00
|
||||
// - New York (UTC-5): 2024-01-15 18:00
|
||||
|
||||
const tokyoDate = '2024-01-16';
|
||||
const newYorkDate = '2024-01-15';
|
||||
|
||||
// Create verses for both dates
|
||||
const tokyoVerse = {
|
||||
id: crypto.randomUUID(),
|
||||
date: tokyoDate,
|
||||
bookId: 'GEN',
|
||||
verseText: 'Tokyo verse',
|
||||
reference: 'Genesis 1:1',
|
||||
};
|
||||
|
||||
const newYorkVerse = {
|
||||
id: crypto.randomUUID(),
|
||||
date: newYorkDate,
|
||||
bookId: 'EXO',
|
||||
verseText: 'New York verse',
|
||||
reference: 'Exodus 1:1',
|
||||
};
|
||||
|
||||
await db.insert(dailyVerses).values([tokyoVerse, newYorkVerse]);
|
||||
|
||||
// Verify Tokyo user gets Jan 16 verse
|
||||
const tokyoResult = await db
|
||||
.select()
|
||||
.from(dailyVerses)
|
||||
.where(eq(dailyVerses.date, tokyoDate))
|
||||
.limit(1);
|
||||
|
||||
expect(tokyoResult).toHaveLength(1);
|
||||
expect(tokyoResult[0].bookId).toBe('GEN');
|
||||
expect(tokyoResult[0].verseText).toBe('Tokyo verse');
|
||||
|
||||
// Verify New York user gets Jan 15 verse
|
||||
const newYorkResult = await db
|
||||
.select()
|
||||
.from(dailyVerses)
|
||||
.where(eq(dailyVerses.date, newYorkDate))
|
||||
.limit(1);
|
||||
|
||||
expect(newYorkResult).toHaveLength(1);
|
||||
expect(newYorkResult[0].bookId).toBe('EXO');
|
||||
expect(newYorkResult[0].verseText).toBe('New York verse');
|
||||
});
|
||||
|
||||
test('verse dates are stored in YYYY-MM-DD format', async () => {
|
||||
const verse = {
|
||||
id: crypto.randomUUID(),
|
||||
date: '2024-01-15',
|
||||
bookId: 'GEN',
|
||||
verseText: 'Test verse',
|
||||
reference: 'Genesis 1:1',
|
||||
};
|
||||
|
||||
await db.insert(dailyVerses).values(verse);
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(dailyVerses)
|
||||
.where(eq(dailyVerses.id, verse.id))
|
||||
.limit(1);
|
||||
|
||||
expect(result[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Completion tracking', () => {
|
||||
test('completions are stored with user local date', async () => {
|
||||
const userId = 'test-user-1';
|
||||
const localDate = '2024-01-16'; // User's local date
|
||||
|
||||
const completion = {
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: localDate,
|
||||
guessCount: 3,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
|
||||
await db.insert(dailyCompletions).values(completion);
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId))
|
||||
.limit(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].date).toBe(localDate);
|
||||
});
|
||||
|
||||
test('users in different timezones can complete different date verses simultaneously', async () => {
|
||||
const tokyoUser = 'tokyo-user';
|
||||
const newYorkUser = 'newyork-user';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: tokyoUser,
|
||||
date: '2024-01-16', // Tokyo: Jan 16
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: newYorkUser,
|
||||
date: '2024-01-15', // New York: Jan 15
|
||||
guessCount: 4,
|
||||
completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const tokyoResult = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, tokyoUser))
|
||||
.limit(1);
|
||||
|
||||
const newYorkResult = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, newYorkUser))
|
||||
.limit(1);
|
||||
|
||||
expect(tokyoResult[0].date).toBe('2024-01-16');
|
||||
expect(newYorkResult[0].date).toBe('2024-01-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Streak calculation', () => {
|
||||
test('consecutive days count as a streak', async () => {
|
||||
const userId = 'streak-user';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-13',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-15',
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
|
||||
// Verify consecutive dates
|
||||
expect(sortedDates).toEqual(['2024-01-13', '2024-01-14', '2024-01-15']);
|
||||
|
||||
// Calculate streak
|
||||
let streak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor(
|
||||
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
streak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(streak).toBe(3);
|
||||
});
|
||||
|
||||
test('current streak is active if last completion was today', async () => {
|
||||
const userId = 'current-streak-user';
|
||||
const userToday = '2024-01-16';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-15',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: userToday,
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-16T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
|
||||
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||
|
||||
expect(isStreakActive).toBe(true);
|
||||
expect(lastPlayedDate).toBe(userToday);
|
||||
});
|
||||
|
||||
test('current streak is active if last completion was yesterday', async () => {
|
||||
const userId = 'yesterday-streak-user';
|
||||
const userToday = '2024-01-16';
|
||||
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-13',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: yesterday,
|
||||
guessCount: 1,
|
||||
completedAt: new Date(yesterday + 'T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||
|
||||
expect(isStreakActive).toBe(true);
|
||||
expect(lastPlayedDate).toBe(yesterday);
|
||||
});
|
||||
|
||||
test('current streak is not active if last completion was 2+ days ago', async () => {
|
||||
const userId = 'broken-streak-user';
|
||||
const userToday = '2024-01-16';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-13',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
|
||||
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||
|
||||
expect(isStreakActive).toBe(false);
|
||||
expect(lastPlayedDate).toBe('2024-01-14'); // 2 days ago
|
||||
});
|
||||
|
||||
test('gap in dates breaks the streak', async () => {
|
||||
const userId = 'gap-user';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-10',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-10T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-11',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-11T12:00:00Z'),
|
||||
},
|
||||
// Gap here (no 01-12)
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-13',
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
|
||||
// Calculate best streak (should be 2, not 4)
|
||||
let bestStreak = 1;
|
||||
let tempStreak = 1;
|
||||
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor(
|
||||
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
tempStreak++;
|
||||
} else {
|
||||
bestStreak = Math.max(bestStreak, tempStreak);
|
||||
tempStreak = 1;
|
||||
}
|
||||
}
|
||||
bestStreak = Math.max(bestStreak, tempStreak);
|
||||
|
||||
expect(bestStreak).toBe(2); // Longest streak is Jan 13-14 or Jan 10-11
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date validation', () => {
|
||||
test('date must be in YYYY-MM-DD format', () => {
|
||||
const validDates = ['2024-01-15', '2023-12-31', '2024-02-29'];
|
||||
|
||||
validDates.forEach((date) => {
|
||||
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid date formats are rejected', () => {
|
||||
const invalidDates = [
|
||||
'2024/01/15', // Wrong separator
|
||||
'01-15-2024', // Wrong order
|
||||
'2024-1-15', // Missing leading zero
|
||||
'2024-01-15T12:00:00Z', // Includes time
|
||||
];
|
||||
|
||||
invalidDates.forEach((date) => {
|
||||
if (date.includes('T')) {
|
||||
expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
} else {
|
||||
expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('crossing year boundary maintains streak', async () => {
|
||||
const userId = 'year-boundary-user';
|
||||
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2023-12-30',
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2023-12-30T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2023-12-31',
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2023-12-31T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-01',
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-01T12:00:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||
|
||||
let streak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor(
|
||||
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
streak++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(streak).toBe(3);
|
||||
});
|
||||
|
||||
// Note: Duplicate prevention is handled by the API endpoint, not at the DB level in these tests
|
||||
// See /api/submit-completion for the unique constraint enforcement
|
||||
});
|
||||
});
|
||||
98
todo.md
98
todo.md
@@ -1,21 +1,33 @@
|
||||
# in progress
|
||||
|
||||
- root menu: classic / imposter mode / impossible mode (complete today's classic and imposter modes to unlock)
|
||||
|
||||
# todo
|
||||
|
||||
- login
|
||||
- login route
|
||||
|
||||
- impossible mode (1904 greek bible) three guesses only.
|
||||
|
||||
- share both classic and impossible mode with both buttons
|
||||
|
||||
- add imposter mode
|
||||
- improve imposter mode
|
||||
|
||||
- Show new/old testament after 3 guesses and section after 7 guesses
|
||||
- Add sections for "first letter", "Canonical/deutero", etc...
|
||||
|
||||
- How do you balance rewarding knowledge vs incentivising learning?
|
||||
|
||||
|
||||
- instructions
|
||||
|
||||
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
|
||||
- imposter mode: out of four options, identify the verse that is not in the Bible
|
||||
- OR out of four options, identify the verse that is not in the same book as the other three options
|
||||
- OR, out of four options, drag them into the
|
||||
- impossible mode: identify which book of the bible the verse is from in less than three guesses.
|
||||
|
||||
- The gambling aspect of hoping you get a verse you already know is VERY strong
|
||||
|
||||
- add login + saved stats + streak etc.
|
||||
|
||||
- add deuterocanonical books
|
||||
@@ -47,6 +59,88 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
||||
|
||||
# done
|
||||
|
||||
## february 22nd
|
||||
|
||||
- New share button design; speech bubbles
|
||||
- Share rate jumped from ~17% to ~27% (n=200) after share button redesign
|
||||
- Updated streak-percentile to count all players from last 30 days and all streaks (in case there are streaks >30 days)
|
||||
- Added copy verse button
|
||||
- Refactored book search input to show progressively more info based on guess count
|
||||
|
||||
## february 21st
|
||||
|
||||
- Added streak counter and streak percentage
|
||||
- Added Rybbit analytics alongside Umami
|
||||
|
||||
## february 18th-19th
|
||||
|
||||
- Refactored game logic into utility modules
|
||||
- Small fixes to Sign In with Apple migrations
|
||||
|
||||
## february 13th
|
||||
|
||||
- Added Sign In with Apple
|
||||
- Added animations on win and guess
|
||||
- Various Apple auth bug fixes
|
||||
|
||||
## february 11th-12th
|
||||
|
||||
- Client-side timezone handling for daily verses (was using server time)
|
||||
- Staggered page load animations
|
||||
- Reordered guesses table with emphasis
|
||||
- Redesigned stats page with dark theme and enhanced statistics
|
||||
|
||||
## february 5th-10th
|
||||
|
||||
- Added login modal and auth infrastructure
|
||||
- Switched to `bun:sqlite`
|
||||
- Support authenticated users in stats and page loading
|
||||
- Anonymous stats migration on sign-in
|
||||
- Test infrastructure and sign-in migration tests
|
||||
|
||||
## february 2nd
|
||||
|
||||
- created rss feed
|
||||
- fixed "first letter" clue edge cases / easter egg
|
||||
- updated ranking formula
|
||||
|
||||
## january 28th
|
||||
|
||||
- add percentile stats, update chapter guess UI
|
||||
- fixed middle statline (removed meaningless %)
|
||||
- added instructions
|
||||
- added email button
|
||||
- added test buttons for 3.0 UI/UX
|
||||
- package upgrades
|
||||
|
||||
## january 26th
|
||||
|
||||
- Make the UI more "wordle-like"
|
||||
- added deployment script (./deploy.sh)
|
||||
- added bluesky button
|
||||
- added "first letter" column
|
||||
- added imposter mode, v0.1 (mom likes it) but needs work
|
||||
|
||||
## january 8th
|
||||
|
||||
- posted on Hacker News and LinkedIn, got 960 visitors in one day
|
||||
|
||||
## january 5th
|
||||
|
||||
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
||||
- Verses ending in semicolons, commas, etc. will be replaced with "..."
|
||||
|
||||
## january 4th
|
||||
|
||||
- For bonus points: guess the verse/psalm number
|
||||
- major UI styling revamp
|
||||
|
||||
-- 2026 --
|
||||
|
||||
## december 30th
|
||||
|
||||
- merged the embeddings/similarity route into production
|
||||
|
||||
## december 27th
|
||||
|
||||
- add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate)
|
||||
|
||||
Reference in New Issue
Block a user