mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
Compare commits
68 Commits
stats
...
1ae2b2ac6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae2b2ac6c | ||
|
|
a188be167b | ||
|
|
e550965086 | ||
|
|
03429b17cc | ||
|
|
3ee7331510 | ||
|
|
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 | ||
|
|
9406498cc9 | ||
|
|
3947e8adb0 | ||
|
|
244113671e | ||
|
|
5b9b2f76f4 | ||
|
|
f7ec0742e1 | ||
|
|
d797b980ea | ||
|
|
ff228fb547 |
@@ -5,7 +5,6 @@
|
|||||||
"Read(./secrets/**)",
|
"Read(./secrets/**)",
|
||||||
"Read(./config/credentials.json)",
|
"Read(./config/credentials.json)",
|
||||||
"Read(./build)",
|
"Read(./build)",
|
||||||
"Read(./**.xml)",
|
|
||||||
"Read(./embeddings**)"
|
"Read(./embeddings**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
14
.env.example
14
.env.example
@@ -1,5 +1,19 @@
|
|||||||
DATABASE_URL=example.db
|
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
|
AUTH_SECRET=your-random-secret-here
|
||||||
APPLE_ID=com.yourcompany.yourapp.client
|
APPLE_ID=com.yourcompany.yourapp.client
|
||||||
APPLE_TEAM_ID=your-team-id
|
APPLE_TEAM_ID=your-team-id
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -28,4 +28,7 @@ vite.config.ts.timestamp-*
|
|||||||
llms-*
|
llms-*
|
||||||
|
|
||||||
embeddings*
|
embeddings*
|
||||||
*.xml
|
*bible.xml
|
||||||
|
engwebu_usfx.xml
|
||||||
|
|
||||||
|
deploy.log
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ bun run preview
|
|||||||
|
|
||||||
# Database operations
|
# Database operations
|
||||||
bun run db:push # Push schema changes to database
|
bun run db:push # Push schema changes to database
|
||||||
bun run db:generate # Generate migrations
|
bun run db:generate # Generate migrations (DO NOT RUN)
|
||||||
bun run db:migrate # Run migrations
|
bun run db:migrate # Run migrations (DO NOT RUN)
|
||||||
bun run db:studio # Open Drizzle Studio GUI
|
bun run db:studio # Open Drizzle Studio GUI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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="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="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="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="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="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>
|
<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>
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# create a new project in the current directory
|
# create a new project in the current directory
|
||||||
npx sv create
|
bunx sv create
|
||||||
|
|
||||||
# create a new project in my-app
|
# create a new project in my-app
|
||||||
npx sv create my-app
|
bunx sv create my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
## 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:
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev
|
bun run dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
# or start the server and open the app in a new browser tab
|
||||||
npm run dev -- --open
|
bun run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -30,9 +30,9 @@ npm run dev -- --open
|
|||||||
To create a production version of your app:
|
To create a production version of your app:
|
||||||
|
|
||||||
```sh
|
```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.
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -6,7 +6,6 @@
|
|||||||
"name": "bibdle",
|
"name": "bibdle",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
},
|
},
|
||||||
@@ -18,11 +17,11 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/bun": "^1.3.8",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"svelte": "^5.48.3",
|
"svelte": "^5.48.5",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -229,6 +228,8 @@
|
|||||||
|
|
||||||
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
"@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/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
@@ -273,6 +274,8 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"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=="],
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
|
|||||||
18
deploy.sh
18
deploy.sh
@@ -3,16 +3,24 @@ set -e
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BUN=$(which bun)
|
||||||
|
|
||||||
echo "Pulling latest changes..."
|
echo "Pulling latest changes..."
|
||||||
git pull
|
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..."
|
echo "Installing dependencies..."
|
||||||
bun i
|
$BUN i
|
||||||
|
|
||||||
echo "Building..."
|
echo "Building..."
|
||||||
bun run build
|
$BUN --bun run build
|
||||||
|
|
||||||
echo "Restarting service..."
|
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||||
sudo systemctl restart bibdle
|
echo "Restarting service ($SERVICE_NAME)..."
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
echo "Done!"
|
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,
|
"when": 1765934144883,
|
||||||
"tag": "0000_clumsy_impossible_man",
|
"tag": "0000_clumsy_impossible_man",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
"version": "3.0.0alpha",
|
"version": "3.0.0alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "bun --bun vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"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:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/bun": "^1.3.8",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -35,7 +37,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"xml2js": "^0.6.2"
|
"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
|
||||||
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>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
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}
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
return chapterCounts[bookId] || 1;
|
return chapterCounts[bookId] || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6 random chapter options including the correct one
|
// Generate 4 random chapter options including the correct one
|
||||||
function generateChapterOptions(
|
function generateChapterOptions(
|
||||||
correctChapter: number,
|
correctChapter: number,
|
||||||
totalChapters: number,
|
totalChapters: number,
|
||||||
@@ -98,14 +98,14 @@
|
|||||||
const options = new Set<number>();
|
const options = new Set<number>();
|
||||||
options.add(correctChapter);
|
options.add(correctChapter);
|
||||||
|
|
||||||
if (totalChapters >= 6) {
|
if (totalChapters >= 4) {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter =
|
const randomChapter =
|
||||||
Math.floor(Math.random() * totalChapters) + 1;
|
Math.floor(Math.random() * totalChapters) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
@@ -167,18 +167,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
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">
|
<div class="text-center">
|
||||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
Bonus Challenge
|
||||||
Guess the chapter for an even higher grade
|
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||||
|
>— guess the chapter for an even higher grade</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
{#each chapterOptions as chapter (chapter)}
|
||||||
>
|
|
||||||
{#each chapterOptions as chapter}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => handleChapterSelect(chapter)}
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
disabled={hasAnswered}
|
disabled={hasAnswered}
|
||||||
@@ -193,8 +193,8 @@
|
|||||||
? isCorrect
|
? isCorrect
|
||||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
: "bg-red-400 text-white border-red-500"
|
: "bg-red-400 text-white border-red-500"
|
||||||
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
|
||||||
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
|
: "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"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}"
|
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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,67 +2,43 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
let timeUntilNext = $state("");
|
let timeUntilNext = $state("");
|
||||||
|
let newVerseReady = $state(false);
|
||||||
let intervalId: number | null = null;
|
let intervalId: number | null = null;
|
||||||
|
let targetTime = 0;
|
||||||
|
|
||||||
function calculateTimeUntilFivePM(): string {
|
function initTarget() {
|
||||||
const now = new Date();
|
const target = 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
|
|
||||||
target.setHours(0, 0, 0, 0);
|
target.setHours(0, 0, 0, 0);
|
||||||
|
if (Date.now() >= target.getTime()) {
|
||||||
// If it's already past midnight, set target to tomorrow midnight
|
|
||||||
if (now.getTime() >= target.getTime()) {
|
|
||||||
target.setDate(target.getDate() + 1);
|
target.setDate(target.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
targetTime = target.getTime();
|
||||||
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 updateTimer() {
|
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(() => {
|
onMount(() => {
|
||||||
|
initTarget();
|
||||||
updateTimer();
|
updateTimer();
|
||||||
intervalId = window.setInterval(updateTimer, 1000);
|
intervalId = window.setInterval(updateTimer, 1000);
|
||||||
});
|
});
|
||||||
@@ -74,15 +50,33 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full flex flex-col flex-1">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
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"
|
||||||
|
>
|
||||||
|
{#if newVerseReady}
|
||||||
|
<p
|
||||||
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
|
|
||||||
Next Verse In
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
<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}
|
{timeUntilNext}
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||||
|
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-2 bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
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"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
|
||||||
A project by George Powell & Silent Summit Co.
|
A project by George Powell & Silent Summit Co.
|
||||||
</p>
|
</p>
|
||||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||||
@@ -32,19 +35,37 @@
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
aria-label="Follow on Bluesky"
|
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" />
|
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
<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
|
<a
|
||||||
href="mailto:george+bibdle@silentsummit.co"
|
href="mailto:george+bibdle@silentsummit.co"
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
aria-label="Send email"
|
aria-label="Send email"
|
||||||
|
data-umami-event="Email clicked"
|
||||||
|
onclick={() => (window as any).rybbit?.event("Email clicked")}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-8 h-8 text-gray-700"
|
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import Button from "$lib/components/Button.svelte";
|
import Button from "$lib/components/Button.svelte";
|
||||||
|
|
||||||
|
let { anonymousId }: { anonymousId: string | null } = $props();
|
||||||
|
|
||||||
|
let seeding = $state(false);
|
||||||
|
|
||||||
|
async function seedHistory(days: number = 10) {
|
||||||
|
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, days })
|
||||||
|
});
|
||||||
|
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() {
|
function clearLocalStorage() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
// Clear all bibdle-related localStorage items
|
// Clear all bibdle-related localStorage items
|
||||||
@@ -86,4 +110,21 @@
|
|||||||
>
|
>
|
||||||
Clear LocalStorage
|
Clear LocalStorage
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={() => seedHistory(1)}
|
||||||
|
disabled={seeding}
|
||||||
|
class="w-full py-4 md:py-2"
|
||||||
|
>
|
||||||
|
{seeding ? "Seeding..." : "Add 1 Day of History"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={() => seedHistory(10)}
|
||||||
|
disabled={seeding}
|
||||||
|
class="w-full py-4 md:py-2"
|
||||||
|
>
|
||||||
|
{seeding ? "Seeding..." : "Seed 10 Days of History"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks } from "$lib/types/bible";
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
|
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
interface Guess {
|
|
||||||
book: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
testament: string;
|
|
||||||
section: string;
|
|
||||||
};
|
|
||||||
testamentMatch: boolean;
|
|
||||||
sectionMatch: boolean;
|
|
||||||
adjacent: boolean;
|
|
||||||
firstLetterMatch: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
guesses,
|
guesses,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
@@ -44,16 +32,27 @@
|
|||||||
(correctBook?.section === "Pauline Epistles" ||
|
(correctBook?.section === "Pauline Epistles" ||
|
||||||
correctBook?.section === "General Epistles") &&
|
correctBook?.section === "General Epistles") &&
|
||||||
correctBook.name[0] === "1";
|
correctBook.name[0] === "1";
|
||||||
const guessStartsWithNumber = guess.book.name[0] === "1";
|
const guessIsEpistlesWithNumber =
|
||||||
|
(guess.book.section === "Pauline Epistles" ||
|
||||||
|
guess.book.section === "General Epistles") &&
|
||||||
|
guess.book.name[0] === "1";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
correctIsEpistlesWithNumber &&
|
correctIsEpistlesWithNumber &&
|
||||||
guessStartsWithNumber &&
|
guessIsEpistlesWithNumber &&
|
||||||
guess.firstLetterMatch
|
guess.firstLetterMatch
|
||||||
) {
|
) {
|
||||||
return "Yes"; // Special wordplay case
|
const words = [
|
||||||
|
"Exactly",
|
||||||
|
"Right",
|
||||||
|
"Yes",
|
||||||
|
"Naturally",
|
||||||
|
"Of course",
|
||||||
|
"Sure",
|
||||||
|
];
|
||||||
|
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
||||||
}
|
}
|
||||||
return guess.book.name[0]; // Normal case: just show the first letter
|
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
||||||
case "testament":
|
case "testament":
|
||||||
return (
|
return (
|
||||||
guess.book.testament.charAt(0).toUpperCase() +
|
guess.book.testament.charAt(0).toUpperCase() +
|
||||||
@@ -67,63 +66,50 @@
|
|||||||
|
|
||||||
{#if !hasGuesses}
|
{#if !hasGuesses}
|
||||||
<Container class="p-6 text-center">
|
<Container class="p-6 text-center">
|
||||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
|
<h2 class="font-triodion text-xl italic mb-3 text-gray-800 dark:text-gray-100">
|
||||||
Instructions
|
Instructions
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-gray-700 leading-relaxed italic">
|
<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
|
Guess what book of the bible you think the verse is from. You will
|
||||||
get clues to tell you if your guess is close or not. Green means the
|
get clues to help you after each guess.
|
||||||
category is correct; red means wrong.
|
|
||||||
</p>
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Column Headers -->
|
<!-- Column Headers -->
|
||||||
<div
|
<div
|
||||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
|
||||||
Book
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
|
||||||
>
|
>
|
||||||
Testament
|
Testament
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
Section
|
Section
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
First Letter
|
First Letter
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Book
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||||
<div class="flex gap-2 justify-center">
|
<div class="flex gap-2 justify-center">
|
||||||
<!-- Book 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-lg animate-flip-in {getBoxColor(
|
|
||||||
guess.book.id === correctBookId,
|
|
||||||
)}"
|
|
||||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
|
||||||
>
|
|
||||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
|
||||||
>{getBoxContent(guess, "book")}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Testament Column -->
|
<!-- Testament Column -->
|
||||||
<div
|
<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(
|
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,
|
guess.testamentMatch,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "testament")}</span
|
>{getBoxContent(guess, "testament")}</span
|
||||||
@@ -136,7 +122,7 @@
|
|||||||
guess.sectionMatch,
|
guess.sectionMatch,
|
||||||
guess.adjacent,
|
guess.adjacent,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "section")}
|
>{getBoxContent(guess, "section")}
|
||||||
@@ -151,12 +137,24 @@
|
|||||||
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(
|
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,
|
guess.firstLetterMatch,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "firstLetter")}</span
|
>{getBoxContent(guess, "firstLetter")}</span
|
||||||
>
|
>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
imposterIndex: number;
|
imposterIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: ImposterData | null = null;
|
let data: ImposterData | null = $state(null);
|
||||||
let clicked: boolean[] = [];
|
let clicked: boolean[] = $state([]);
|
||||||
let gameOver = false;
|
let gameOver = $state(false);
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
let error: string | null = null;
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
try {
|
try {
|
||||||
@@ -76,7 +76,12 @@
|
|||||||
if (/^[a-z]/.test(formatted)) {
|
if (/^[a-z]/.test(formatted)) {
|
||||||
formatted = "..." + formatted;
|
formatted = "..." + formatted;
|
||||||
}
|
}
|
||||||
formatted = formatted.replace(/[,:;-—]$/, "...");
|
// Replace trailing punctuation with ellipsis
|
||||||
|
// Preserve closing quotes/brackets that may have been added
|
||||||
|
formatted = formatted.replace(
|
||||||
|
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||||
|
"...$1",
|
||||||
|
);
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -87,7 +92,7 @@
|
|||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
<p>Error: {error}</p>
|
<p>Error: {error}</p>
|
||||||
<button on:click={newGame}>Retry</button>
|
<button onclick={newGame}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
<!-- <div class="instructions">
|
<!-- <div class="instructions">
|
||||||
@@ -101,7 +106,7 @@
|
|||||||
class:clicked={clicked[i]}
|
class:clicked={clicked[i]}
|
||||||
class:correct={clicked[i] && i === data.imposterIndex}
|
class:correct={clicked[i] && i === data.imposterIndex}
|
||||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||||
on:click={() => handleClick(i)}
|
onclick={() => handleClick(i)}
|
||||||
disabled={gameOver}
|
disabled={gameOver}
|
||||||
>
|
>
|
||||||
{formatVerse(verse)}
|
{formatVerse(verse)}
|
||||||
@@ -114,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<button on:click={newGame}>New Game</button>
|
<button onclick={newGame}>New Game</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -136,11 +141,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instructions {
|
/*.instructions {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
.verses {
|
.verses {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,29 +1,154 @@
|
|||||||
<script lang="ts">
|
<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(
|
type DisplayMode = "simple" | "testament" | "sections";
|
||||||
|
|
||||||
|
const displayMode = $derived<DisplayMode>(
|
||||||
|
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredBooks = $derived(
|
||||||
bibleBooks.filter((book) =>
|
bibleBooks.filter((book) =>
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
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) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
if (e.key === "Enter" && firstBookId) {
|
||||||
submitGuess(filteredBooks[0].id);
|
submitGuess(firstBookId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showBanner = $derived(guessCount >= 3);
|
||||||
|
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#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">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
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"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -35,13 +160,13 @@
|
|||||||
<input
|
<input
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
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 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
|
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}
|
onkeydown={handleKeydown}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button
|
<button
|
||||||
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
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 = "")}
|
onclick={() => (searchQuery = "")}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
@@ -51,6 +176,7 @@
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -62,31 +188,122 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
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"
|
||||||
>
|
>
|
||||||
{#each filteredBooks as book (book.id)}
|
{#if displayMode === "simple"}
|
||||||
<li>
|
{#each simpleGroup.books as book (book.id)}
|
||||||
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
<button
|
<button
|
||||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
{guessedIds.has(book.id)
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
onclick={() => submitGuess(book.id)}
|
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
|
<span
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
class="font-semibold {guessedIds.has(book.id)
|
||||||
? 'line-through text-gray-500'
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
: ''}">{book.name}</span
|
: ''}"
|
||||||
>
|
|
||||||
<span class="ml-auto text-sm opacity-75"
|
|
||||||
>({book.testament.toUpperCase()})</span
|
|
||||||
>
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</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}
|
{:else if searchQuery}
|
||||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
@@ -13,26 +15,73 @@
|
|||||||
? dailyVerse.reference
|
? dailyVerse.reference
|
||||||
.replace(/^Psalms /, "Psalm ")
|
.replace(/^Psalms /, "Psalm ")
|
||||||
.replace(/\s(\d+):/, " ?:")
|
.replace(/\s(\d+):/, " ?:")
|
||||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||||
);
|
);
|
||||||
let displayVerseText = $derived(
|
let displayVerseText = $derived(
|
||||||
dailyVerse.verseText
|
dailyVerse.verseText
|
||||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||||
.replace(/[,:;-—]$/, "...")
|
.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>
|
</script>
|
||||||
|
|
||||||
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
<Container
|
||||||
|
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||||
|
>
|
||||||
<blockquote
|
<blockquote
|
||||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
|
||||||
>
|
>
|
||||||
{displayVerseText}
|
{displayVerseText}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
{#if isWon}
|
<div
|
||||||
|
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||||
|
style="max-height: {showReference ? '200px' : '0px'};"
|
||||||
|
>
|
||||||
|
{#if showReference}
|
||||||
<p
|
<p
|
||||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
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}
|
{displayReference}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||||
import {
|
import {
|
||||||
getBookById,
|
getVerseSnippet,
|
||||||
toOrdinal,
|
shareResult,
|
||||||
getNextGradeMessage,
|
copyToClipboard as clipboardCopy,
|
||||||
} from "$lib/utils/game";
|
} from "$lib/utils/share";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
import CountdownTimer from "./CountdownTimer.svelte";
|
import CountdownTimer from "./CountdownTimer.svelte";
|
||||||
|
import StreakCounter from "./StreakCounter.svelte";
|
||||||
import ChapterGuess from "./ChapterGuess.svelte";
|
import ChapterGuess from "./ChapterGuess.svelte";
|
||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
@@ -25,7 +26,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
grade,
|
|
||||||
statsData,
|
statsData,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
handleShare,
|
handleShare,
|
||||||
@@ -35,6 +35,24 @@
|
|||||||
guessCount,
|
guessCount,
|
||||||
reference,
|
reference,
|
||||||
onChapterGuessCompleted,
|
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();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -42,6 +60,24 @@
|
|||||||
typeof navigator !== "undefined" && "share" in navigator,
|
typeof navigator !== "undefined" && "share" in navigator,
|
||||||
);
|
);
|
||||||
let copySuccess = $state(false);
|
let copySuccess = $state(false);
|
||||||
|
let bubbleCopied = $state(false);
|
||||||
|
let copyTracked = $state(false);
|
||||||
|
let showSnippetOption = $state(false);
|
||||||
|
let includeSnippet = $state(false);
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
// List of congratulations messages with weights
|
// List of congratulations messages with weights
|
||||||
const congratulationsMessages: WeightedMessage[] = [
|
const congratulationsMessages: WeightedMessage[] = [
|
||||||
@@ -57,9 +93,9 @@
|
|||||||
if (guessCount === 1) {
|
if (guessCount === 1) {
|
||||||
const n = Math.random();
|
const n = Math.random();
|
||||||
if (n < 0.99) {
|
if (n < 0.99) {
|
||||||
return "🌟 First try! 🌟";
|
return "First try!";
|
||||||
} else {
|
} else {
|
||||||
return "🗣️ Axios! 🗣️";
|
return "Axios!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,66 +122,27 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
|
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"
|
||||||
>
|
>
|
||||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
|
<div class="flex flex-col gap-3">
|
||||||
|
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||||
{congratulationsMessage} The verse is from
|
{congratulationsMessage} The verse is from
|
||||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||||
|
>{bookName}</span
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-lg sm:text-xl md:text-2xl mt-4">
|
<p class="text-lg sm:text-xl md:text-2xl">
|
||||||
You guessed correctly after {guessCount}
|
You guessed correctly after {guessCount}
|
||||||
{guessCount === 1 ? "guess" : "guesses"}.
|
{guessCount === 1 ? "guess" : "guesses"}.
|
||||||
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
|
|
||||||
>{grade}</span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
|
<!-- {#if streak >= 7}
|
||||||
<div class="flex justify-center mt-6">
|
<p
|
||||||
{#if hasWebShare}
|
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||||
<!-- mobile and arc in production -->
|
|
||||||
<button
|
|
||||||
onclick={handleShare}
|
|
||||||
data-umami-event="Share"
|
|
||||||
class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
|
|
||||||
>
|
>
|
||||||
📤 Share
|
Thank you for making BIBDLE part of your daily routine!
|
||||||
</button>
|
</p>
|
||||||
<button
|
{/if} -->
|
||||||
onclick={() => {
|
|
||||||
copyToClipboard();
|
|
||||||
copySuccess = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
copySuccess = false;
|
|
||||||
}, 3000);
|
|
||||||
}}
|
|
||||||
data-umami-event="Copy to Clipboard"
|
|
||||||
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
|
|
||||||
copySuccess
|
|
||||||
? "bg-white/30"
|
|
||||||
: "bg-white/70 hover:bg-white/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{copySuccess ? "✅ Copied!" : "📋 Copy"}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<!-- dev mode and desktop browsers -->
|
|
||||||
<button
|
|
||||||
onclick={handleShare}
|
|
||||||
data-umami-event="Copy to Clipboard"
|
|
||||||
class={`text-2xl font-bold p-4 ${
|
|
||||||
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
|
|
||||||
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
|
||||||
>
|
|
||||||
{copied ? "✅ Copied!" : "📋 Share"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if guessCount !== 1}
|
|
||||||
<p class="pt-6 big-text text-gray-700!">
|
|
||||||
{getNextGradeMessage(guessCount)}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<!-- S++ Bonus Challenge for first try -->
|
<!-- S++ Bonus Challenge for first try -->
|
||||||
@@ -157,12 +154,21 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||||
|
<div class="flex-2 min-w-0 flex flex-col">
|
||||||
<CountdownTimer />
|
<CountdownTimer />
|
||||||
|
</div>
|
||||||
|
{#if streak > 0}
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
|
<StreakCounter {streak} {streakPercentile} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Display -->
|
<!-- Statistics Display -->
|
||||||
{#if statsData}
|
{#if statsData}
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
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
|
<div
|
||||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||||
@@ -171,7 +177,7 @@
|
|||||||
<!-- Solve Rank Column -->
|
<!-- Solve Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
#{statsData.solveRank}
|
#{statsData.solveRank}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +190,7 @@
|
|||||||
<!-- Guess Rank Column -->
|
<!-- Guess Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
{toOrdinal(statsData.guessRank)}
|
{toOrdinal(statsData.guessRank)}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +212,7 @@
|
|||||||
<!-- Average Column -->
|
<!-- Average Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
{statsData.averageGuesses}
|
{statsData.averageGuesses}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,11 +226,99 @@
|
|||||||
</Container>
|
</Container>
|
||||||
{:else if !statsSubmitted}
|
{:else if !statsSubmitted}
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
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>
|
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||||
</Container>
|
</Container>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -239,7 +333,269 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
:global(.fade-in) {
|
||||||
animation: fadeIn 0.5s ease-out;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
|
||||||
export * from './utils/game';
|
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
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// 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
|
session: table.session
|
||||||
})
|
})
|
||||||
.from(table.session)
|
.from(table.session)
|
||||||
@@ -79,3 +79,83 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
|
|||||||
path: '/'
|
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 { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
import Database from 'better-sqlite3';
|
import { Database } from 'bun:sqlite';
|
||||||
import * as schema from './schema';
|
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 });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
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(),
|
||||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
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', {
|
export const session = sqliteTable('session', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -30,11 +36,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
|||||||
anonymousId: text('anonymous_id').notNull(),
|
anonymousId: text('anonymous_id').notNull(),
|
||||||
date: text('date').notNull(),
|
date: text('date').notNull(),
|
||||||
guessCount: integer('guess_count').notNull(),
|
guessCount: integer('guess_count').notNull(),
|
||||||
|
guesses: text('guesses'), // nullable; only stored for logged-in users
|
||||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => ({
|
}, (table) => [
|
||||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||||
dateIndex: index('date_idx').on(table.date),
|
index('date_idx').on(table.date),
|
||||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
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;
|
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 });
|
||||||
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';
|
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 {
|
export function getBookById(id: string): BibleBook | undefined {
|
||||||
return bibleBooks.find((b) => b.id === id);
|
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);
|
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 === 1) return "S+";
|
||||||
if (numGuesses === 2) return "A+";
|
if (numGuesses === 2) return "A+";
|
||||||
if (numGuesses === 3) return "A";
|
if (numGuesses === 3) return "A";
|
||||||
@@ -31,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toOrdinal(n: number): string {
|
export function toOrdinal(n: number): string {
|
||||||
if (n >= 11 && n <= 13) {
|
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||||
return `${n}th`;
|
return `${n}th`;
|
||||||
}
|
}
|
||||||
const mod = n % 10;
|
const mod = n % 10;
|
||||||
|
|||||||
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 ? `,${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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,21 @@ export interface UserStats {
|
|||||||
guessCount: number;
|
guessCount: number;
|
||||||
grade: string;
|
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 {
|
export function getGradeColor(grade: string): string {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -21,11 +21,6 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<!-- <script
|
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||||
defer
|
|
||||||
src="https://umami.snail.city/script.js"
|
|
||||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
|
||||||
data-domains="bibdle.com,www.bibdle.com"
|
|
||||||
></script> -->
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -1,47 +1,14 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
import { eq, sql, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { fail } from '@sveltejs/kit';
|
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';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
async function getTodayVerse(): Promise<DailyVerse> {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
// 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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dailyVerse,
|
user: locals.user,
|
||||||
correctBookId: dailyVerse.bookId,
|
session: locals.session
|
||||||
correctBook
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,13 +58,20 @@ export const actions: Actions = {
|
|||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
const guessRank = betterGuesses + 1;
|
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
|
// Average guesses
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
|
||||||
|
|
||||||
import type { PageProps } from "./$types";
|
import type { PageProps } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||||
@@ -11,42 +10,50 @@
|
|||||||
import Credits from "$lib/components/Credits.svelte";
|
import Credits from "$lib/components/Credits.svelte";
|
||||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||||
import { getGrade } from "$lib/utils/game";
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
|
||||||
interface Guess {
|
import { evaluateGuess } from "$lib/utils/game";
|
||||||
book: BibleBook;
|
import {
|
||||||
testamentMatch: boolean;
|
generateShareText,
|
||||||
sectionMatch: boolean;
|
shareResult,
|
||||||
adjacent: boolean;
|
copyToClipboard as clipboardCopy,
|
||||||
firstLetterMatch: boolean;
|
} 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";
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let correctBookId = $derived(data.correctBookId);
|
let correctBookId = $derived(data.correctBookId);
|
||||||
|
let correctBook = $derived(data.correctBook);
|
||||||
let guesses = $state<Guess[]>([]);
|
let user = $derived(data.user);
|
||||||
|
let session = $derived(data.session);
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let isDev = $state(false);
|
let isDev = $state(false);
|
||||||
let chapterGuessCompleted = $state(false);
|
let authModalOpen = $state(false);
|
||||||
let chapterCorrect = $state(false);
|
let showWinScreen = $state(false);
|
||||||
|
let statsData = $state<StatsData | null>(null);
|
||||||
|
let streak = $state(0);
|
||||||
|
let streakPercentile = $state<number | null>(null);
|
||||||
|
|
||||||
let anonymousId = $state("");
|
const persistence = createGamePersistence(
|
||||||
let statsSubmitted = $state(false);
|
() => dailyVerse.date,
|
||||||
let statsData = $state<{
|
() => dailyVerse.reference,
|
||||||
solveRank: number;
|
() => correctBookId,
|
||||||
guessRank: number;
|
() => user,
|
||||||
totalSolves: number;
|
);
|
||||||
averageGuesses: number;
|
|
||||||
tiedCount: number;
|
|
||||||
percentile: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
|
let guessedIds = $derived(
|
||||||
|
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
|
||||||
|
);
|
||||||
|
|
||||||
const currentDate = $derived(
|
const currentDate = $derived(
|
||||||
new Date().toLocaleDateString("en-US", {
|
new Date().toLocaleDateString("en-US", {
|
||||||
@@ -57,351 +64,184 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
let isWon = $derived(
|
||||||
let grade = $derived(
|
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||||
isWon
|
|
||||||
? guesses.length === 1 && chapterCorrect
|
|
||||||
? "S++"
|
|
||||||
: getGrade(
|
|
||||||
guesses.length,
|
|
||||||
getBookById(correctBookId)?.popularity ?? 0,
|
|
||||||
)
|
|
||||||
: "",
|
|
||||||
);
|
);
|
||||||
let blurChapter = $derived(
|
let blurChapter = $derived(
|
||||||
isWon && guesses.length === 1 && !chapterGuessCompleted,
|
isWon &&
|
||||||
|
persistence.guesses.length === 1 &&
|
||||||
|
!persistence.chapterGuessCompleted,
|
||||||
);
|
);
|
||||||
|
|
||||||
function getBookById(id: string): BibleBook | undefined {
|
async function submitGuess(bookId: string) {
|
||||||
return bibleBooks.find((b) => b.id === id);
|
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||||
}
|
|
||||||
|
|
||||||
function isAdjacent(id1: string, id2: string): boolean {
|
const guess = evaluateGuess(bookId, correctBookId);
|
||||||
const b1 = getBookById(id1);
|
if (!guess) return;
|
||||||
const b2 = getBookById(id2);
|
|
||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitGuess(bookId: string) {
|
if (persistence.guesses.length === 0) {
|
||||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
|
||||||
|
|
||||||
const book = getBookById(bookId);
|
|
||||||
if (!book) return;
|
|
||||||
|
|
||||||
const correctBook = getBookById(correctBookId);
|
|
||||||
if (!correctBook) return;
|
|
||||||
|
|
||||||
const testamentMatch = book.testament === correctBook.testament;
|
|
||||||
const sectionMatch = book.section === correctBook.section;
|
|
||||||
const adjacent = isAdjacent(bookId, 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.name[0] === "1";
|
|
||||||
const guessStartsWithNumber = book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
|
||||||
correctIsEpistlesWithNumber && guessStartsWithNumber
|
|
||||||
? true
|
|
||||||
: book.name[0].toUpperCase() ===
|
|
||||||
correctBook.name[0].toUpperCase();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (guesses.length === 0) {
|
|
||||||
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
||||||
if (
|
if (
|
||||||
localStorage.getItem(key) !== "true" &&
|
|
||||||
browser &&
|
browser &&
|
||||||
|
localStorage.getItem(key) !== "true" &&
|
||||||
(window as any).umami
|
(window as any).umami
|
||||||
) {
|
) {
|
||||||
(window as any).umami.track("First guess");
|
(window as any).umami.track("First guess");
|
||||||
|
(window as any).rybbit?.event("First guess");
|
||||||
localStorage.setItem(key, "true");
|
localStorage.setItem(key, "true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guesses = [
|
persistence.guesses = [guess, ...persistence.guesses];
|
||||||
{
|
|
||||||
book,
|
|
||||||
testamentMatch,
|
|
||||||
sectionMatch,
|
|
||||||
adjacent,
|
|
||||||
firstLetterMatch,
|
|
||||||
},
|
|
||||||
...guesses,
|
|
||||||
];
|
|
||||||
|
|
||||||
searchQuery = "";
|
searchQuery = "";
|
||||||
}
|
|
||||||
|
|
||||||
function generateUUID(): string {
|
if (
|
||||||
// Try native randomUUID if available
|
guess.book.id === correctBookId &&
|
||||||
if (typeof window.crypto.randomUUID === "function") {
|
browser &&
|
||||||
return window.crypto.randomUUID();
|
persistence.anonymousId
|
||||||
}
|
) {
|
||||||
|
statsData = await submitCompletion({
|
||||||
// Fallback UUID v4 generator for older browsers
|
anonymousId: persistence.anonymousId,
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize anonymous ID
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
anonymousId = getOrCreateAnonymousId();
|
|
||||||
if ((window as any).umami) {
|
|
||||||
(window as any).umami.identify(anonymousId);
|
|
||||||
}
|
|
||||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
|
||||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
|
||||||
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
|
||||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
|
||||||
if (chapterGuessCompleted) {
|
|
||||||
const saved = localStorage.getItem(chapterGuessKey);
|
|
||||||
if (saved) {
|
|
||||||
const data = JSON.parse(saved);
|
|
||||||
const match = dailyVerse.reference.match(/\s(\d+):/);
|
|
||||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
|
||||||
chapterCorrect = data.selectedChapter === correctChapter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
isDev = window.location.host === "localhost:5173";
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Apply same first letter logic as in submitGuess
|
|
||||||
const correctIsEpistlesWithNumber =
|
|
||||||
correctBook.section === "Pauline Epistles" &&
|
|
||||||
correctBook.name[0] === "1";
|
|
||||||
const guessStartsWithNumber = book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
|
||||||
correctIsEpistlesWithNumber && guessStartsWithNumber
|
|
||||||
? true
|
|
||||||
: book.name[0].toUpperCase() ===
|
|
||||||
correctBook.name[0].toUpperCase();
|
|
||||||
|
|
||||||
return {
|
|
||||||
book,
|
|
||||||
testamentMatch,
|
|
||||||
sectionMatch,
|
|
||||||
adjacent,
|
|
||||||
firstLetterMatch,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$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,
|
date: dailyVerse.date,
|
||||||
guessCount: guesses.length,
|
guessCount: persistence.guesses.length,
|
||||||
};
|
guesses: persistence.guesses.map((g) => g.book.id),
|
||||||
|
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
|
if (statsData) {
|
||||||
|
persistence.markStatsSubmitted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
// Reload when the user returns to a stale tab on a new calendar day
|
||||||
console.log("Stats response:", result);
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
if (result.success && result.stats) {
|
const loadedDate = new Date().toLocaleDateString("en-CA");
|
||||||
console.log("Setting stats data:", result.stats);
|
|
||||||
statsData = result.stats;
|
function onVisibilityChange() {
|
||||||
statsSubmitted = true;
|
if (document.hidden) return;
|
||||||
localStorage.setItem(
|
const now = new Date().toLocaleDateString("en-CA");
|
||||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
if (now !== loadedDate) {
|
||||||
"true",
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
onVisibilityChange,
|
||||||
);
|
);
|
||||||
} 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) return;
|
||||||
|
isDev =
|
||||||
|
window.location.host === "localhost:5173" ||
|
||||||
|
window.location.host === "test.bibdle.com";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay showing win screen until GuessesTable animation completes
|
||||||
|
$effect(() => {
|
||||||
|
if (!isWon) {
|
||||||
|
showWinScreen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persistence.isWinAlreadyTracked()) {
|
||||||
|
showWinScreen = true;
|
||||||
|
} else {
|
||||||
|
const animationDelay = 1800;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
showWinScreen = true;
|
||||||
|
}, animationDelay);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track win analytics
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser || !isWon) return;
|
if (!browser || !isWon) return;
|
||||||
const key = `bibdle-win-tracked-${dailyVerse.date}`;
|
const isNew = persistence.markWinTracked();
|
||||||
if (localStorage.getItem(key) === "true") return;
|
if (isNew && (window as any).umami) {
|
||||||
if ((window as any).umami) {
|
|
||||||
(window as any).umami.track("Guessed correctly", {
|
(window as any).umami.track("Guessed correctly", {
|
||||||
totalGuesses: guesses.length,
|
totalGuesses: persistence.guesses.length,
|
||||||
|
});
|
||||||
|
(window as any).rybbit?.event("Guessed correctly", {
|
||||||
|
totalGuesses: persistence.guesses.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
localStorage.setItem(key, "true");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateShareText(): string {
|
// Fetch streak when the player wins
|
||||||
const emojis = guesses
|
$effect(() => {
|
||||||
.slice()
|
if (!browser || !isWon || !persistence.anonymousId) return;
|
||||||
.reverse()
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
.map((guess) => {
|
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||||
if (guess.book.id === correctBookId) return "✅";
|
streak = result;
|
||||||
if (guess.adjacent) return "‼️";
|
if (result >= 2) {
|
||||||
if (guess.sectionMatch) return "🟩";
|
fetchStreakPercentile(result, localDate).then((p) => {
|
||||||
if (guess.testamentMatch) return "🟧";
|
streakPercentile = p;
|
||||||
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}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
|
||||||
siteUrl,
|
|
||||||
].join("\n");
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function share() {
|
function getShareText(): string {
|
||||||
if (!browser) return;
|
return generateShareText({
|
||||||
|
guesses: persistence.guesses,
|
||||||
const shareText = generateShareText();
|
correctBookId,
|
||||||
|
dailyVerseDate: dailyVerse.date,
|
||||||
try {
|
chapterCorrect: persistence.chapterCorrect,
|
||||||
if ("share" in navigator) {
|
isLoggedIn: !!user,
|
||||||
await (navigator as any).share({ text: shareText });
|
streak,
|
||||||
} else {
|
origin: window.location.origin,
|
||||||
await (navigator as any).clipboard.writeText(shareText);
|
verseText: dailyVerse.verseText,
|
||||||
}
|
});
|
||||||
} 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() {
|
function handleShare() {
|
||||||
@@ -410,7 +250,7 @@
|
|||||||
if (useClipboard) {
|
if (useClipboard) {
|
||||||
copied = true;
|
copied = true;
|
||||||
}
|
}
|
||||||
share()
|
shareResult(getShareText())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (useClipboard) {
|
if (useClipboard) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -424,81 +264,178 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
|
||||||
<title>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."
|
|
||||||
/> -->
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
|
<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">
|
<div class="w-full max-w-3xl mx-auto px-4">
|
||||||
<h1
|
<h1
|
||||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
|
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 />
|
<TitleAnimation />
|
||||||
<div class="font-normal"></div>
|
<div class="font-normal"></div>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||||
<span class="big-text"
|
<span class="big-text"
|
||||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||||
>
|
>
|
||||||
<div class="mt-4">
|
|
||||||
<a
|
|
||||||
href="/stats?anonymousId={anonymousId}"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
📊 View Stats
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="animate-fade-in-up animate-delay-200">
|
||||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
{:else}
|
<SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
|
||||||
|
</div>
|
||||||
|
{:else if showWinScreen}
|
||||||
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
<WinScreen
|
<WinScreen
|
||||||
{grade}
|
|
||||||
{statsData}
|
{statsData}
|
||||||
{correctBookId}
|
{correctBookId}
|
||||||
{handleShare}
|
{handleShare}
|
||||||
{copyToClipboard}
|
copyToClipboard={handleCopyToClipboard}
|
||||||
bind:copied
|
bind:copied
|
||||||
{statsSubmitted}
|
statsSubmitted={persistence.statsSubmitted}
|
||||||
guessCount={guesses.length}
|
guessCount={persistence.guesses.length}
|
||||||
reference={dailyVerse.reference}
|
reference={dailyVerse.reference}
|
||||||
onChapterGuessCompleted={() => {
|
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||||
chapterGuessCompleted = true;
|
shareText={getShareText()}
|
||||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
verseText={dailyVerse.verseText}
|
||||||
const saved = localStorage.getItem(key);
|
{streak}
|
||||||
if (saved) {
|
{streakPercentile}
|
||||||
const data = JSON.parse(saved);
|
|
||||||
const match =
|
|
||||||
dailyVerse.reference.match(/\s(\d+):/);
|
|
||||||
const correctChapter = match
|
|
||||||
? parseInt(match[1], 10)
|
|
||||||
: 1;
|
|
||||||
chapterCorrect =
|
|
||||||
data.selectedChapter === correctChapter;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<GuessesTable {guesses} {correctBookId} />
|
<div class="animate-fade-in-up animate-delay-600">
|
||||||
|
<GuessesTable guesses={persistence.guesses} {correctBookId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if isWon}
|
{#if isWon}
|
||||||
|
<div class="animate-fade-in-up animate-delay-800">
|
||||||
<Credits />
|
<Credits />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isDev}
|
{#if isDev}
|
||||||
<DevButtons />
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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, days = 10 } = 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 <= days; 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
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: streak < 2 ? 0 : streak });
|
||||||
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyCompletions } from '$lib/server/db/schema';
|
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 { json } from '@sveltejs/kit';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const { anonymousId, date, guessCount } = await request.json();
|
const { anonymousId, date, guessCount, guesses } = await request.json();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
||||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
anonymousId,
|
anonymousId,
|
||||||
date,
|
date,
|
||||||
guessCount,
|
guessCount,
|
||||||
|
guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
|
||||||
completedAt,
|
completedAt,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -44,11 +45,9 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
// Solve rank: position in time-ordered list
|
// Solve rank: position in time-ordered list
|
||||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||||
|
|
||||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||||
const uniqueBetterGuessCounts = new Set(
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
const guessRank = betterGuesses + 1;
|
||||||
);
|
|
||||||
const guessRank = uniqueBetterGuessCounts.size + 1;
|
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
@@ -70,69 +69,3 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
return json({ error: 'Failed to submit completion' }, { status: 500 });
|
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 DISTINCT guess counts are better (grouped ranking)
|
|
||||||
const uniqueBetterGuessCounts = new Set(
|
|
||||||
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
|
||||||
);
|
|
||||||
const guessRank = uniqueBetterGuessCounts.size + 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, tiedCount, percentile }
|
|
||||||
});
|
|
||||||
} 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,6 +10,12 @@ html, body {
|
|||||||
background: oklch(89.126% 0.06134 298.626);
|
background: oklch(89.126% 0.06134 298.626);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html, body {
|
||||||
|
background: oklch(18% 0.03 298.626);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.big-text {
|
.big-text {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -17,3 +23,41 @@ html, body {
|
|||||||
color: rgb(107 114 128);
|
color: rgb(107 114 128);
|
||||||
font-weight: 700;
|
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;
|
||||||
|
}
|
||||||
@@ -1,24 +1,42 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema';
|
import { dailyCompletions, dailyVerses, type DailyCompletion } from '$lib/server/db/schema';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
const anonymousId = url.searchParams.get('anonymousId');
|
// Check if user is authenticated
|
||||||
|
if (!locals.user) {
|
||||||
if (!anonymousId) {
|
|
||||||
return {
|
return {
|
||||||
stats: null,
|
stats: null,
|
||||||
error: 'No anonymous ID provided'
|
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 {
|
try {
|
||||||
// Get all completions for this user
|
// Get all completions for this user
|
||||||
const completions = await db
|
const completions = await db
|
||||||
.select()
|
.select()
|
||||||
.from(dailyCompletions)
|
.from(dailyCompletions)
|
||||||
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
.where(eq(dailyCompletions.anonymousId, userId))
|
||||||
.orderBy(desc(dailyCompletions.date));
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
if (completions.length === 0) {
|
if (completions.length === 0) {
|
||||||
@@ -38,8 +56,15 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
},
|
},
|
||||||
currentStreak: 0,
|
currentStreak: 0,
|
||||||
bestStreak: 0,
|
bestStreak: 0,
|
||||||
recentCompletions: []
|
recentCompletions: [],
|
||||||
}
|
worstDay: null,
|
||||||
|
bestBook: null,
|
||||||
|
mostSeenBook: null,
|
||||||
|
totalBooksSeenOT: 0,
|
||||||
|
totalBooksSeenNT: 0
|
||||||
|
},
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +96,11 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
|
|
||||||
if (sortedDates.length > 0) {
|
if (sortedDates.length > 0) {
|
||||||
// Check if current streak is active (includes today or yesterday)
|
// Check if current streak is active (includes today or yesterday)
|
||||||
const today = new Date().toISOString().split('T')[0];
|
// Use the user's local date passed from the client
|
||||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
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];
|
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||||
|
|
||||||
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
||||||
@@ -118,6 +146,66 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
grade: getGradeFromGuesses(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 {
|
return {
|
||||||
stats: {
|
stats: {
|
||||||
totalSolves,
|
totalSolves,
|
||||||
@@ -125,15 +213,34 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
gradeDistribution,
|
gradeDistribution,
|
||||||
currentStreak,
|
currentStreak,
|
||||||
bestStreak,
|
bestStreak,
|
||||||
recentCompletions
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching user stats:', error);
|
console.error('Error fetching user stats:', error);
|
||||||
return {
|
return {
|
||||||
stats: null,
|
stats: null,
|
||||||
error: 'Failed to fetch stats'
|
error: 'Failed to fetch stats',
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
import Container from "$lib/components/Container.svelte";
|
||||||
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
import {
|
import {
|
||||||
getGradeColor,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
getStreakMessage,
|
type UserStats,
|
||||||
getPerformanceMessage,
|
|
||||||
type UserStats
|
|
||||||
} from "$lib/utils/stats";
|
} from "$lib/utils/stats";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
stats: UserStats | null;
|
stats: UserStats | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
user?: any;
|
||||||
|
session?: any;
|
||||||
|
requiresAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
let authModalOpen = $state(false);
|
||||||
|
let anonymousId = $state("");
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
@@ -31,25 +35,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
if (!anonymousId) {
|
|
||||||
goto("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no anonymousId in URL, redirect with it
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
if (!url.searchParams.get('anonymousId')) {
|
|
||||||
url.searchParams.set('anonymousId', anonymousId);
|
|
||||||
goto(url.pathname + url.search);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getGradePercentage(count: number, total: number): number {
|
function getBookName(bookId: string): string {
|
||||||
return total > 0 ? Math.round((count / total) * 100) : 0;
|
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$inspect(data);
|
$inspect(data);
|
||||||
@@ -57,34 +48,72 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Stats | Bibdle</title>
|
<title>Stats | Bibdle</title>
|
||||||
<meta name="description" content="View your Bibdle game statistics and performance" />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="View your Bibdle game statistics and performance"
|
||||||
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4">
|
<div
|
||||||
<div class="max-w-4xl mx-auto">
|
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 -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-6 md:mb-8">
|
||||||
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1>
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||||
<p class="text-gray-600">Track your Bibdle performance over time</p>
|
Your Stats
|
||||||
<div class="mt-4">
|
</h1>
|
||||||
|
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||||
|
Track your Bibdle performance over time
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
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
|
← Back to Game
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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-600">Loading your stats...</p>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if data.error}
|
{:else if data.error}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
|
<div
|
||||||
<p class="text-red-700">{data.error}</p>
|
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
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
@@ -95,112 +124,209 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if !data.stats}
|
{:else if !data.stats}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto">
|
<Container class="p-8 max-w-md mx-auto">
|
||||||
<p class="text-yellow-700">No stats available.</p>
|
<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
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
|
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
|
Start Playing
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@const stats = data.stats}
|
{@const stats = data.stats}
|
||||||
|
|
||||||
<!-- Overview Cards -->
|
<!-- Key Stats Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
||||||
<!-- Total Solves -->
|
<!-- Current Streak -->
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
<Container class="p-4 md:p-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold text-amber-600 mb-2">{stats.totalSolves}</div>
|
<div class="text-2xl md:text-3xl mb-1">🔥</div>
|
||||||
<div class="text-gray-600">Total Solves</div>
|
<div
|
||||||
{#if stats.totalSolves > 0}
|
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
>
|
||||||
{getPerformanceMessage(stats.avgGuesses)}
|
{stats.currentStreak}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div
|
||||||
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||||
|
>
|
||||||
|
Current Streak
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Average Guesses -->
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
<Container class="p-4 md:p-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold text-blue-600 mb-2">{stats.avgGuesses}</div>
|
<div class="text-2xl md:text-3xl mb-1">🎯</div>
|
||||||
<div class="text-gray-600">Avg. Guesses</div>
|
<div
|
||||||
<div class="text-sm text-gray-500 mt-1">per solve</div>
|
class="text-2xl md:text-3xl font-bold text-blue-400 mb-1"
|
||||||
|
>
|
||||||
|
{stats.avgGuesses}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Current Streak -->
|
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
|
|
||||||
<div class="text-gray-600">Current Streak</div>
|
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
|
||||||
{getStreakMessage(stats.currentStreak)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Grade Distribution -->
|
|
||||||
{#if stats.totalSolves > 0}
|
{#if stats.totalSolves > 0}
|
||||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
<!-- Book Stats Grid -->
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2>
|
<div
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6"
|
||||||
{#each Object.entries(stats.gradeDistribution) as [grade, count]}
|
>
|
||||||
{@const percentage = getGradePercentage(count, stats.totalSolves)}
|
<!-- Worst Day -->
|
||||||
<div class="text-center">
|
{#if stats.worstDay}
|
||||||
<div class="mb-2">
|
<Container class="p-4 md:p-6">
|
||||||
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}">
|
<div class="flex items-start gap-3">
|
||||||
{grade}
|
<div class="text-3xl md:text-4xl">😅</div>
|
||||||
</span>
|
<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>
|
||||||
<div class="text-2xl font-bold text-gray-800">{count}</div>
|
<div
|
||||||
<div class="text-sm text-gray-500">{percentage}%</div>
|
class="text-xl md:text-2xl font-bold text-red-400 truncate"
|
||||||
|
>
|
||||||
|
{stats.worstDay.guessCount} guesses
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<div
|
||||||
</div>
|
class="text-xs md:text-sm text-gray-400"
|
||||||
</div>
|
>
|
||||||
|
{formatDate(stats.worstDay.date)}
|
||||||
<!-- Streak Info -->
|
|
||||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">Streak Information</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
|
|
||||||
<div class="text-gray-600">Current Streak</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-3xl font-bold text-purple-600 mb-2">{stats.bestStreak}</div>
|
|
||||||
<div class="text-gray-600">Best Streak</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Container>
|
||||||
<!-- Recent Performance -->
|
|
||||||
{#if stats.recentCompletions.length > 0}
|
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">Recent Performance</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each stats.recentCompletions as completion}
|
|
||||||
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">{formatDate(completion.date)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-gray-600">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
|
|
||||||
<span class="px-2 py-1 rounded text-sm font-semibold {getGradeColor(completion.grade)}">
|
|
||||||
{completion.grade}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/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>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
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;
|
export default config;
|
||||||
|
|||||||
234
tests/bible.test.ts
Normal file
234
tests/bible.test.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
getBookById,
|
||||||
|
getBookByNumber,
|
||||||
|
getBooksByTestament,
|
||||||
|
getBooksBySection,
|
||||||
|
isAdjacent,
|
||||||
|
bookNumberToId,
|
||||||
|
bookIdToNumber,
|
||||||
|
bibleBooks,
|
||||||
|
} from "$lib/server/bible";
|
||||||
|
|
||||||
|
describe("bibleBooks data integrity", () => {
|
||||||
|
test("contains exactly 66 books", () => {
|
||||||
|
expect(bibleBooks).toHaveLength(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("order numbers are 1 through 66 with no gaps or duplicates", () => {
|
||||||
|
const orders = bibleBooks.map((b) => b.order).sort((a, b) => a - b);
|
||||||
|
for (let i = 0; i < 66; i++) {
|
||||||
|
expect(orders[i]).toBe(i + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("book IDs are unique", () => {
|
||||||
|
const ids = bibleBooks.map((b) => b.id);
|
||||||
|
const unique = new Set(ids);
|
||||||
|
expect(unique.size).toBe(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every book has a non-empty name", () => {
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
expect(book.name.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Old Testament has 39 books", () => {
|
||||||
|
expect(bibleBooks.filter((b) => b.testament === "old")).toHaveLength(39);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New Testament has 27 books", () => {
|
||||||
|
expect(bibleBooks.filter((b) => b.testament === "new")).toHaveLength(27);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Genesis is first and Revelation is last", () => {
|
||||||
|
const sorted = [...bibleBooks].sort((a, b) => a.order - b.order);
|
||||||
|
expect(sorted[0].id).toBe("GEN");
|
||||||
|
expect(sorted[65].id).toBe("REV");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Matthew is the first New Testament book", () => {
|
||||||
|
const nt = bibleBooks
|
||||||
|
.filter((b) => b.testament === "new")
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
expect(nt[0].id).toBe("MAT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("section counts", () => {
|
||||||
|
test("Law: 5 books", () => {
|
||||||
|
expect(getBooksBySection("Law")).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("History: 13 books (12 OT + Acts)", () => {
|
||||||
|
expect(getBooksBySection("History")).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wisdom: 5 books", () => {
|
||||||
|
expect(getBooksBySection("Wisdom")).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Major Prophets: 5 books", () => {
|
||||||
|
expect(getBooksBySection("Major Prophets")).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Minor Prophets: 12 books", () => {
|
||||||
|
expect(getBooksBySection("Minor Prophets")).toHaveLength(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gospels: 4 books", () => {
|
||||||
|
expect(getBooksBySection("Gospels")).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Pauline Epistles: 13 books", () => {
|
||||||
|
expect(getBooksBySection("Pauline Epistles")).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("General Epistles: 8 books", () => {
|
||||||
|
expect(getBooksBySection("General Epistles")).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Apocalyptic: 1 book (Revelation)", () => {
|
||||||
|
const books = getBooksBySection("Apocalyptic");
|
||||||
|
expect(books).toHaveLength(1);
|
||||||
|
expect(books[0].id).toBe("REV");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all sections sum to 66", () => {
|
||||||
|
const total =
|
||||||
|
getBooksBySection("Law").length +
|
||||||
|
getBooksBySection("History").length +
|
||||||
|
getBooksBySection("Wisdom").length +
|
||||||
|
getBooksBySection("Major Prophets").length +
|
||||||
|
getBooksBySection("Minor Prophets").length +
|
||||||
|
getBooksBySection("Gospels").length +
|
||||||
|
getBooksBySection("Pauline Epistles").length +
|
||||||
|
getBooksBySection("General Epistles").length +
|
||||||
|
getBooksBySection("Apocalyptic").length;
|
||||||
|
expect(total).toBe(66);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBookById", () => {
|
||||||
|
test("returns Genesis for GEN", () => {
|
||||||
|
const book = getBookById("GEN");
|
||||||
|
expect(book).toBeDefined();
|
||||||
|
expect(book!.name).toBe("Genesis");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns Revelation for REV", () => {
|
||||||
|
const book = getBookById("REV");
|
||||||
|
expect(book!.name).toBe("Revelation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for unknown ID", () => {
|
||||||
|
expect(getBookById("UNKNOWN")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBookByNumber", () => {
|
||||||
|
test("1 → Genesis", () => {
|
||||||
|
expect(getBookByNumber(1)!.id).toBe("GEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("66 → Revelation", () => {
|
||||||
|
expect(getBookByNumber(66)!.id).toBe("REV");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("40 → Matthew (first NT book)", () => {
|
||||||
|
expect(getBookByNumber(40)!.id).toBe("MAT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("0 → undefined", () => {
|
||||||
|
expect(getBookByNumber(0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("67 → undefined", () => {
|
||||||
|
expect(getBookByNumber(67)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBooksByTestament", () => {
|
||||||
|
test("old returns 39 books", () => {
|
||||||
|
expect(getBooksByTestament("old")).toHaveLength(39);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new returns 27 books", () => {
|
||||||
|
expect(getBooksByTestament("new")).toHaveLength(27);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all OT books have testament = old", () => {
|
||||||
|
for (const book of getBooksByTestament("old")) {
|
||||||
|
expect(book.testament).toBe("old");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all NT books have testament = new", () => {
|
||||||
|
for (const book of getBooksByTestament("new")) {
|
||||||
|
expect(book.testament).toBe("new");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAdjacent", () => {
|
||||||
|
test("Genesis and Exodus are adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "EXO")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacency is symmetric", () => {
|
||||||
|
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Malachi and Matthew are adjacent across testament boundary", () => {
|
||||||
|
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Jude and Revelation are adjacent", () => {
|
||||||
|
expect(isAdjacent("JUD", "REV")).toBe(true); // 65, 66
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same book is not adjacent to itself", () => {
|
||||||
|
expect(isAdjacent("GEN", "GEN")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("books two apart are not adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for invalid IDs", () => {
|
||||||
|
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bookNumberToId / bookIdToNumber lookup tables", () => {
|
||||||
|
test("bookNumberToId[1] is GEN", () => {
|
||||||
|
expect(bookNumberToId[1]).toBe("GEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bookNumberToId[66] is REV", () => {
|
||||||
|
expect(bookNumberToId[66]).toBe("REV");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bookIdToNumber['GEN'] is 1", () => {
|
||||||
|
expect(bookIdToNumber["GEN"]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bookIdToNumber['REV'] is 66", () => {
|
||||||
|
expect(bookIdToNumber["REV"]).toBe(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trip: number → ID → number", () => {
|
||||||
|
for (let i = 1; i <= 66; i++) {
|
||||||
|
const id = bookNumberToId[i];
|
||||||
|
expect(bookIdToNumber[id]).toBe(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trip: ID → number → ID", () => {
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
const num = bookIdToNumber[book.id];
|
||||||
|
expect(bookNumberToId[num]).toBe(book.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
271
tests/game.test.ts
Normal file
271
tests/game.test.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
evaluateGuess,
|
||||||
|
getBookById,
|
||||||
|
getFirstLetter,
|
||||||
|
getGrade,
|
||||||
|
getNextGradeMessage,
|
||||||
|
isAdjacent,
|
||||||
|
toOrdinal,
|
||||||
|
} from "$lib/utils/game";
|
||||||
|
|
||||||
|
describe("getBookById", () => {
|
||||||
|
test("returns correct book for a valid ID", () => {
|
||||||
|
const book = getBookById("GEN");
|
||||||
|
expect(book).toBeDefined();
|
||||||
|
expect(book!.name).toBe("Genesis");
|
||||||
|
expect(book!.order).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the last book by ID", () => {
|
||||||
|
const book = getBookById("REV");
|
||||||
|
expect(book).toBeDefined();
|
||||||
|
expect(book!.name).toBe("Revelation");
|
||||||
|
expect(book!.order).toBe(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for an invalid ID", () => {
|
||||||
|
expect(getBookById("INVALID")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for an empty string", () => {
|
||||||
|
expect(getBookById("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is case-sensitive", () => {
|
||||||
|
expect(getBookById("gen")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAdjacent", () => {
|
||||||
|
test("consecutive books are adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "EXO")).toBe(true); // 1, 2
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacency is symmetric", () => {
|
||||||
|
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("books two apart are not adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the same book is not adjacent to itself", () => {
|
||||||
|
expect(isAdjacent("GEN", "GEN")).toBe(false); // diff = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works across testament boundary (Malachi / Matthew)", () => {
|
||||||
|
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||||
|
});
|
||||||
|
|
||||||
|
test("far-apart books are not adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "REV")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for unknown IDs", () => {
|
||||||
|
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||||
|
expect(isAdjacent("GEN", "FAKE")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFirstLetter", () => {
|
||||||
|
test("returns first letter of a normal book name", () => {
|
||||||
|
expect(getFirstLetter("Genesis")).toBe("G");
|
||||||
|
expect(getFirstLetter("Revelation")).toBe("R");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips leading digits and returns first letter", () => {
|
||||||
|
expect(getFirstLetter("1 Samuel")).toBe("S");
|
||||||
|
expect(getFirstLetter("2 Kings")).toBe("K");
|
||||||
|
expect(getFirstLetter("1 Corinthians")).toBe("C");
|
||||||
|
expect(getFirstLetter("3 John")).toBe("J");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns first letter of multi-word names", () => {
|
||||||
|
expect(getFirstLetter("Song of Solomon")).toBe("S");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("evaluateGuess", () => {
|
||||||
|
test("returns null for an invalid guess ID", () => {
|
||||||
|
expect(evaluateGuess("INVALID", "GEN")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for an invalid correct ID", () => {
|
||||||
|
expect(evaluateGuess("GEN", "INVALID")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exact book match: testamentMatch and sectionMatch are true, adjacent is false", () => {
|
||||||
|
const result = evaluateGuess("GEN", "GEN");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.book.id).toBe("GEN");
|
||||||
|
expect(result!.testamentMatch).toBe(true);
|
||||||
|
expect(result!.sectionMatch).toBe(true);
|
||||||
|
expect(result!.adjacent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same section implies same testament", () => {
|
||||||
|
// Genesis and Exodus are both OT Law
|
||||||
|
const result = evaluateGuess("GEN", "EXO");
|
||||||
|
expect(result!.testamentMatch).toBe(true);
|
||||||
|
expect(result!.sectionMatch).toBe(true);
|
||||||
|
expect(result!.adjacent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same testament, different section", () => {
|
||||||
|
// Genesis (Law) vs Joshua (History) — both OT
|
||||||
|
const result = evaluateGuess("GEN", "JOS");
|
||||||
|
expect(result!.testamentMatch).toBe(true);
|
||||||
|
expect(result!.sectionMatch).toBe(false);
|
||||||
|
expect(result!.adjacent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different testament, no match", () => {
|
||||||
|
// Genesis (OT) vs Matthew (NT)
|
||||||
|
const result = evaluateGuess("GEN", "MAT");
|
||||||
|
expect(result!.testamentMatch).toBe(false);
|
||||||
|
expect(result!.sectionMatch).toBe(false);
|
||||||
|
expect(result!.adjacent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacent books across testament boundary", () => {
|
||||||
|
// Malachi (OT, 39) and Matthew (NT, 40)
|
||||||
|
const result = evaluateGuess("MAL", "MAT");
|
||||||
|
expect(result!.adjacent).toBe(true);
|
||||||
|
expect(result!.testamentMatch).toBe(false);
|
||||||
|
expect(result!.sectionMatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacent books within same testament and section", () => {
|
||||||
|
// Hosea (28) and Joel (29), both Minor Prophets
|
||||||
|
const result = evaluateGuess("HOS", "JOL");
|
||||||
|
expect(result!.adjacent).toBe(true);
|
||||||
|
expect(result!.testamentMatch).toBe(true);
|
||||||
|
expect(result!.sectionMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firstLetterMatch: same first letter", () => {
|
||||||
|
// Genesis and Galatians both start with G
|
||||||
|
const result = evaluateGuess("GEN", "GAL");
|
||||||
|
expect(result!.firstLetterMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firstLetterMatch: different first letter", () => {
|
||||||
|
// Genesis (G) vs Matthew (M)
|
||||||
|
const result = evaluateGuess("GEN", "MAT");
|
||||||
|
expect(result!.firstLetterMatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firstLetterMatch is case-insensitive", () => {
|
||||||
|
// Both start with J but from different contexts — Jeremiah vs Joel
|
||||||
|
const result = evaluateGuess("JER", "JOL");
|
||||||
|
expect(result!.firstLetterMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("special case: two Epistle '1' books always firstLetterMatch", () => {
|
||||||
|
// 1 Corinthians (Pauline) vs 1 John (General) — both Epistles starting with "1"
|
||||||
|
const result = evaluateGuess("1CO", "1JN");
|
||||||
|
expect(result!.firstLetterMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("special case: Epistle '1' book vs non-Epistle '1' book — no special treatment", () => {
|
||||||
|
// 1 Corinthians (Pauline Epistles) vs 1 Samuel (History)
|
||||||
|
// Correct is NOT Epistles, so special case doesn't apply
|
||||||
|
const result = evaluateGuess("1CO", "1SA");
|
||||||
|
// getFirstLetter("1 Corinthians") = "C", getFirstLetter("1 Samuel") = "S" → false
|
||||||
|
expect(result!.firstLetterMatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("special case only triggers when BOTH are Epistle '1' books", () => {
|
||||||
|
// 2 Corinthians (Pauline, starts with "2") vs 1 John (General, starts with "1")
|
||||||
|
// guessIsEpistlesWithNumber requires name[0] === "1", so 2CO fails
|
||||||
|
const result = evaluateGuess("2CO", "1JN");
|
||||||
|
// getFirstLetter("2 Corinthians") = "C", getFirstLetter("1 John") = "J" → false
|
||||||
|
expect(result!.firstLetterMatch).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getGrade", () => {
|
||||||
|
test("1 guess → S+", () => expect(getGrade(1)).toBe("S+"));
|
||||||
|
test("2 guesses → A+", () => expect(getGrade(2)).toBe("A+"));
|
||||||
|
test("3 guesses → A", () => expect(getGrade(3)).toBe("A"));
|
||||||
|
test("4 guesses → B+", () => expect(getGrade(4)).toBe("B+"));
|
||||||
|
test("6 guesses → B+", () => expect(getGrade(6)).toBe("B+"));
|
||||||
|
test("7 guesses → B", () => expect(getGrade(7)).toBe("B"));
|
||||||
|
test("10 guesses → B", () => expect(getGrade(10)).toBe("B"));
|
||||||
|
test("11 guesses → C+", () => expect(getGrade(11)).toBe("C+"));
|
||||||
|
test("15 guesses → C+", () => expect(getGrade(15)).toBe("C+"));
|
||||||
|
test("16 guesses → C", () => expect(getGrade(16)).toBe("C"));
|
||||||
|
test("100 guesses → C", () => expect(getGrade(100)).toBe("C"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getNextGradeMessage", () => {
|
||||||
|
test("returns empty string at top grade", () => {
|
||||||
|
expect(getNextGradeMessage(1)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade A+ shows 1 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(2)).toBe("Next grade: 1 guess or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade A shows 2 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(3)).toBe("Next grade: 2 guesses or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade B+ shows 3 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(4)).toBe("Next grade: 3 guesses or less");
|
||||||
|
expect(getNextGradeMessage(6)).toBe("Next grade: 3 guesses or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade B shows 6 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(7)).toBe("Next grade: 6 guesses or less");
|
||||||
|
expect(getNextGradeMessage(10)).toBe("Next grade: 6 guesses or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade C+ shows 10 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(11)).toBe("Next grade: 10 guesses or less");
|
||||||
|
expect(getNextGradeMessage(15)).toBe("Next grade: 10 guesses or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade C shows 15 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(16)).toBe("Next grade: 15 guesses or less");
|
||||||
|
expect(getNextGradeMessage(50)).toBe("Next grade: 15 guesses or less");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toOrdinal", () => {
|
||||||
|
test("1st, 2nd, 3rd", () => {
|
||||||
|
expect(toOrdinal(1)).toBe("1st");
|
||||||
|
expect(toOrdinal(2)).toBe("2nd");
|
||||||
|
expect(toOrdinal(3)).toBe("3rd");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4-10 use th", () => {
|
||||||
|
expect(toOrdinal(4)).toBe("4th");
|
||||||
|
expect(toOrdinal(10)).toBe("10th");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("11, 12, 13 use th (not st/nd/rd)", () => {
|
||||||
|
expect(toOrdinal(11)).toBe("11th");
|
||||||
|
expect(toOrdinal(12)).toBe("12th");
|
||||||
|
expect(toOrdinal(13)).toBe("13th");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("21, 22, 23 use st/nd/rd", () => {
|
||||||
|
expect(toOrdinal(21)).toBe("21st");
|
||||||
|
expect(toOrdinal(22)).toBe("22nd");
|
||||||
|
expect(toOrdinal(23)).toBe("23rd");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("101, 102, 103 use st/nd/rd", () => {
|
||||||
|
expect(toOrdinal(101)).toBe("101st");
|
||||||
|
expect(toOrdinal(102)).toBe("102nd");
|
||||||
|
expect(toOrdinal(103)).toBe("103rd");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("111, 112, 113 use th", () => {
|
||||||
|
expect(toOrdinal(111)).toBe("111th");
|
||||||
|
expect(toOrdinal(112)).toBe("112th");
|
||||||
|
expect(toOrdinal(113)).toBe("113th");
|
||||||
|
});
|
||||||
|
});
|
||||||
339
tests/share.test.ts
Normal file
339
tests/share.test.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { generateShareText, getVerseSnippet } from "$lib/utils/share";
|
||||||
|
import { getBookById } from "$lib/utils/game";
|
||||||
|
import type { Guess } from "$lib/utils/game";
|
||||||
|
|
||||||
|
// Helpers to build Guess objects without calling evaluateGuess
|
||||||
|
function makeGuess(bookId: string, overrides: Partial<Omit<Guess, "book">> = {}): Guess {
|
||||||
|
const book = getBookById(bookId)!;
|
||||||
|
return {
|
||||||
|
book,
|
||||||
|
testamentMatch: false,
|
||||||
|
sectionMatch: false,
|
||||||
|
adjacent: false,
|
||||||
|
firstLetterMatch: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORRECT_BOOK_ID = "GEN";
|
||||||
|
|
||||||
|
const exactGuess = makeGuess("GEN", {
|
||||||
|
testamentMatch: true,
|
||||||
|
sectionMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjacentGuess = makeGuess("EXO", {
|
||||||
|
testamentMatch: true,
|
||||||
|
sectionMatch: true,
|
||||||
|
adjacent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionGuess = makeGuess("LEV", {
|
||||||
|
testamentMatch: true,
|
||||||
|
sectionMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testamentGuess = makeGuess("JOS", {
|
||||||
|
testamentMatch: true,
|
||||||
|
sectionMatch: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const noMatchGuess = makeGuess("MAT", {
|
||||||
|
testamentMatch: false,
|
||||||
|
sectionMatch: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — emoji mapping", () => {
|
||||||
|
test("exact match → ✅", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("✅");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacent book → ‼️", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [adjacentGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("‼️");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("section match → 🟩", () => {
|
||||||
|
// LEV matches section (Law) but is not adjacent to GEN (order 1 vs 3)
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [sectionGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("🟩");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("testament match only → 🟧", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [testamentGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("🟧");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no match → 🟥", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [noMatchGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("🟥");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — guess count wording", () => {
|
||||||
|
test("1 guess uses singular 'guess'", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("1 guess,");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple guesses uses plural 'guesses'", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [noMatchGuess, testamentGuess, exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("3 guesses,");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — streak display", () => {
|
||||||
|
test("streak > 1 is shown with fire emoji", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
streak: 5,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("5 days 🔥");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streak of 1 is not shown", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
streak: 1,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).not.toContain("🔥");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("undefined streak is not shown", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).not.toContain("🔥");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — chapter star", () => {
|
||||||
|
test("1 guess + chapterCorrect → ⭐ appended to emoji line", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: true,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("✅ ⭐");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple guesses + chapterCorrect → no star (only awarded for hole-in-one)", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [noMatchGuess, exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: true,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).not.toContain("⭐");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1 guess + chapterCorrect false → no star", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).not.toContain("⭐");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — login book emoji", () => {
|
||||||
|
test("logged in uses 📜", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: true,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("📜");
|
||||||
|
expect(text).not.toContain("📖");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not logged in uses 📖", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("📖");
|
||||||
|
expect(text).not.toContain("📜");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — date formatting", () => {
|
||||||
|
test("date is formatted as 'Mon DD, YYYY'", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("Jan 15, 2025");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — guess order", () => {
|
||||||
|
test("guesses are reversed in the emoji line (first guess last)", () => {
|
||||||
|
// noMatchGuess first, then exactGuess — reversed output: ✅🟥
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [noMatchGuess, exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
const lines = text.split("\n");
|
||||||
|
expect(lines[2]).toBe("✅🟥");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getVerseSnippet", () => {
|
||||||
|
test("wraps output in curly double quotes", () => {
|
||||||
|
const result = getVerseSnippet("Hello world");
|
||||||
|
expect(result.startsWith("\u201C")).toBe(true);
|
||||||
|
expect(result.endsWith("\u201D")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("short verse (fewer than 10 words) returns full text", () => {
|
||||||
|
const result = getVerseSnippet("For God so loved");
|
||||||
|
// No punctuation search happens, returns all words
|
||||||
|
expect(result).toContain("For God so loved");
|
||||||
|
expect(result).toContain("...");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verse with no punctuation in range returns first 25 words", () => {
|
||||||
|
const words = Array.from({ length: 30 }, (_, i) => `word${i + 1}`);
|
||||||
|
const verse = words.join(" ");
|
||||||
|
const result = getVerseSnippet(verse);
|
||||||
|
// Should contain up to 25 words
|
||||||
|
expect(result).toContain("word25");
|
||||||
|
expect(result).not.toContain("word26");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates at punctuation between words 10 and 25", () => {
|
||||||
|
// 12 words before comma, rest after
|
||||||
|
const verse =
|
||||||
|
"one two three four five six seven eight nine ten eleven twelve, thirteen fourteen fifteen twenty";
|
||||||
|
const result = getVerseSnippet(verse);
|
||||||
|
// The comma is after word 12, which is between word 10 and 25
|
||||||
|
expect(result).toContain("twelve");
|
||||||
|
expect(result).not.toContain("thirteen");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("punctuation before word 10 does not trigger truncation", () => {
|
||||||
|
// Comma is after word 5 — before the search window starts at word 10
|
||||||
|
const verse =
|
||||||
|
"one two three four five, six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen";
|
||||||
|
const result = getVerseSnippet(verse);
|
||||||
|
// The comma at word 5 is before start of search range, so we continue
|
||||||
|
// The snippet should contain word 10 at minimum
|
||||||
|
expect(result).toContain("ten");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not include trailing whitespace before ellipsis", () => {
|
||||||
|
const verse =
|
||||||
|
"one two three four five six seven eight nine ten eleven twelve, rest of verse here";
|
||||||
|
const result = getVerseSnippet(verse);
|
||||||
|
// trimEnd is applied before adding ..., so no space before ...
|
||||||
|
expect(result).not.toMatch(/\s\.\.\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
129
tests/stats.test.ts
Normal file
129
tests/stats.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
getGradeColor,
|
||||||
|
getPerformanceMessage,
|
||||||
|
getStreakMessage,
|
||||||
|
} from "$lib/utils/stats";
|
||||||
|
|
||||||
|
describe("getGradeColor", () => {
|
||||||
|
test("S++ → purple", () => {
|
||||||
|
expect(getGradeColor("S++")).toBe("text-purple-600 bg-purple-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("S+ → yellow", () => {
|
||||||
|
expect(getGradeColor("S+")).toBe("text-yellow-600 bg-yellow-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("A+ → green", () => {
|
||||||
|
expect(getGradeColor("A+")).toBe("text-green-600 bg-green-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("A → light green", () => {
|
||||||
|
expect(getGradeColor("A")).toBe("text-green-500 bg-green-50");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B+ → blue", () => {
|
||||||
|
expect(getGradeColor("B+")).toBe("text-blue-600 bg-blue-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B → light blue", () => {
|
||||||
|
expect(getGradeColor("B")).toBe("text-blue-500 bg-blue-50");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C+ → orange", () => {
|
||||||
|
expect(getGradeColor("C+")).toBe("text-orange-600 bg-orange-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C → red", () => {
|
||||||
|
expect(getGradeColor("C")).toBe("text-red-600 bg-red-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown grade → gray fallback", () => {
|
||||||
|
expect(getGradeColor("X")).toBe("text-gray-600 bg-gray-100");
|
||||||
|
expect(getGradeColor("")).toBe("text-gray-600 bg-gray-100");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDate", () => {
|
||||||
|
test("formats a mid-year date", () => {
|
||||||
|
expect(formatDate("2024-07-04")).toBe("Jul 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats a January date", () => {
|
||||||
|
expect(formatDate("2024-01-15")).toBe("Jan 15");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats the last day of the year", () => {
|
||||||
|
expect(formatDate("2023-12-31")).toBe("Dec 31");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats a single-digit day without leading zero", () => {
|
||||||
|
expect(formatDate("2025-03-01")).toBe("Mar 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("year in input does not appear in output", () => {
|
||||||
|
expect(formatDate("2024-06-20")).not.toContain("2024");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStreakMessage", () => {
|
||||||
|
test("0 → prompt to start", () => {
|
||||||
|
expect(getStreakMessage(0)).toBe("Start your streak today!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1 → encouragement", () => {
|
||||||
|
expect(getStreakMessage(1)).toBe("Keep it going!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2 → X days strong", () => {
|
||||||
|
expect(getStreakMessage(2)).toBe("2 days strong!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6 → X days strong (upper bound of that range)", () => {
|
||||||
|
expect(getStreakMessage(6)).toBe("6 days strong!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7 → week streak message", () => {
|
||||||
|
expect(getStreakMessage(7)).toBe("7 day streak - amazing!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("29 → upper bound of week-streak range", () => {
|
||||||
|
expect(getStreakMessage(29)).toBe("29 day streak - amazing!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("30 → unstoppable message", () => {
|
||||||
|
expect(getStreakMessage(30)).toBe("30 days - you're unstoppable!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("100 → unstoppable message", () => {
|
||||||
|
expect(getStreakMessage(100)).toBe("100 days - you're unstoppable!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPerformanceMessage", () => {
|
||||||
|
test("≤ 2 guesses → exceptional", () => {
|
||||||
|
expect(getPerformanceMessage(1)).toBe("Exceptional performance!");
|
||||||
|
expect(getPerformanceMessage(2)).toBe("Exceptional performance!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("≤ 4 guesses → great", () => {
|
||||||
|
expect(getPerformanceMessage(2.1)).toBe("Great performance!");
|
||||||
|
expect(getPerformanceMessage(4)).toBe("Great performance!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("≤ 6 guesses → good", () => {
|
||||||
|
expect(getPerformanceMessage(4.1)).toBe("Good performance!");
|
||||||
|
expect(getPerformanceMessage(6)).toBe("Good performance!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("≤ 8 guesses → room for improvement", () => {
|
||||||
|
expect(getPerformanceMessage(6.1)).toBe("Room for improvement!");
|
||||||
|
expect(getPerformanceMessage(8)).toBe("Room for improvement!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("> 8 guesses → keep practicing", () => {
|
||||||
|
expect(getPerformanceMessage(8.1)).toBe("Keep practicing!");
|
||||||
|
expect(getPerformanceMessage(20)).toBe("Keep practicing!");
|
||||||
|
});
|
||||||
|
});
|
||||||
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
|
||||||
|
});
|
||||||
|
});
|
||||||
51
todo.md
51
todo.md
@@ -59,10 +59,55 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
# done
|
# done
|
||||||
|
|
||||||
|
## feb 26th
|
||||||
|
|
||||||
|
- Added dark mode
|
||||||
|
- Removed URL from share text (Wordle said it was ratchet)
|
||||||
|
- added option for sharing with verse snippet (hidden on share text first copy)
|
||||||
|
|
||||||
|
## 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
|
## february 2nd
|
||||||
|
|
||||||
- created rss feed
|
- created rss feed
|
||||||
- fixed "first letter" clue edge cases
|
- fixed "first letter" clue edge cases / easter egg
|
||||||
- updated ranking formula
|
- updated ranking formula
|
||||||
|
|
||||||
## january 28th
|
## january 28th
|
||||||
@@ -82,6 +127,10 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
- added "first letter" column
|
- added "first letter" column
|
||||||
- added imposter mode, v0.1 (mom likes it) but needs work
|
- 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
|
## 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...
|
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
||||||
|
|||||||
Reference in New Issue
Block a user