mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
67 Commits
stats
...
a188be167b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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(./config/credentials.json)",
|
||||
"Read(./build)",
|
||||
"Read(./**.xml)",
|
||||
"Read(./embeddings**)"
|
||||
]
|
||||
}
|
||||
|
||||
14
.env.example
14
.env.example
@@ -1,5 +1,19 @@
|
||||
DATABASE_URL=example.db
|
||||
|
||||
PUBLIC_SITE_URL=https://bibdle.com
|
||||
|
||||
# nodemailer
|
||||
SMTP_USERNAME=email@example.com
|
||||
SMTP_TOKEN=TOKEN
|
||||
SMTP_SERVER=smtp.example.com
|
||||
SMTP_PORT=port
|
||||
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
|
||||
|
||||
# sign in with Discord
|
||||
|
||||
# sign in with google
|
||||
|
||||
# sign in with apple
|
||||
AUTH_SECRET=your-random-secret-here
|
||||
APPLE_ID=com.yourcompany.yourapp.client
|
||||
APPLE_TEAM_ID=your-team-id
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -28,4 +28,7 @@ vite.config.ts.timestamp-*
|
||||
llms-*
|
||||
|
||||
embeddings*
|
||||
*.xml
|
||||
*bible.xml
|
||||
engwebu_usfx.xml
|
||||
|
||||
deploy.log
|
||||
|
||||
@@ -59,8 +59,8 @@ bun run preview
|
||||
|
||||
# Database operations
|
||||
bun run db:push # Push schema changes to database
|
||||
bun run db:generate # Generate migrations
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:generate # Generate migrations (DO NOT RUN)
|
||||
bun run db:migrate # Run migrations (DO NOT RUN)
|
||||
bun run db:studio # Open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
|
||||
@@ -24143,7 +24143,7 @@
|
||||
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
||||
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
||||
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
||||
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
|
||||
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.”</verse>
|
||||
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
||||
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
||||
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
||||
@@ -33616,4 +33616,4 @@
|
||||
</chapter>
|
||||
</book>
|
||||
</testament>
|
||||
</bible>
|
||||
</bible>
|
||||
|
||||
12
README.md
12
README.md
@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
bunx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
bunx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
@@ -19,10 +19,10 @@ npx sv create my-app
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
bun run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
bun run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
@@ -30,9 +30,9 @@ npm run dev -- --open
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
bun run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
You can preview the production build with `bun run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
9
bun.lock
9
bun.lock
@@ -6,7 +6,6 @@
|
||||
"name": "bibdle",
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"xml2js": "^0.6.2",
|
||||
},
|
||||
@@ -18,11 +17,11 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^22.19.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"svelte": "^5.48.3",
|
||||
"svelte": "^5.48.5",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"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/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
@@ -273,6 +274,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
18
deploy.sh
18
deploy.sh
@@ -3,16 +3,24 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUN=$(which bun)
|
||||
|
||||
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..."
|
||||
bun i
|
||||
$BUN i
|
||||
|
||||
echo "Building..."
|
||||
bun run build
|
||||
$BUN --bun run build
|
||||
|
||||
echo "Restarting service..."
|
||||
sudo systemctl restart bibdle
|
||||
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||
echo "Restarting service ($SERVICE_NAME)..."
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
|
||||
echo "Done!"
|
||||
|
||||
11
drizzle.test.config.ts
Normal file
11
drizzle.test.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
if (!process.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: { url: process.env.TEST_DATABASE_URL },
|
||||
verbose: true,
|
||||
strict: true
|
||||
});
|
||||
10
drizzle/0002_outstanding_hiroim.sql
Normal file
10
drizzle/0002_outstanding_hiroim.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `email` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint
|
||||
CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`);
|
||||
275
drizzle/meta/0002_snapshot.json
Normal file
275
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||
"prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d",
|
||||
"tables": {
|
||||
"daily_completions": {
|
||||
"name": "daily_completions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anonymous_id": {
|
||||
"name": "anonymous_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guess_count": {
|
||||
"name": "guess_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"anonymous_id_date_idx": {
|
||||
"name": "anonymous_id_date_idx",
|
||||
"columns": [
|
||||
"anonymous_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"date_idx": {
|
||||
"name": "date_idx",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"date_guess_idx": {
|
||||
"name": "date_guess_idx",
|
||||
"columns": [
|
||||
"date",
|
||||
"guess_count"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"daily_completions_anonymous_id_date_unique": {
|
||||
"name": "daily_completions_anonymous_id_date_unique",
|
||||
"columns": [
|
||||
"anonymous_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"daily_verses": {
|
||||
"name": "daily_verses",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"book_id": {
|
||||
"name": "book_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verse_text": {
|
||||
"name": "verse_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"daily_verses_date_unique": {
|
||||
"name": "daily_verses_date_unique",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"apple_id": {
|
||||
"name": "apple_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_private": {
|
||||
"name": "is_private",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_apple_id_unique": {
|
||||
"name": "user_apple_id_unique",
|
||||
"columns": [
|
||||
"apple_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@
|
||||
"when": 1765934144883,
|
||||
"tag": "0000_clumsy_impossible_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770266674489,
|
||||
"tag": "0001_loose_kree",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1770961427714,
|
||||
"tag": "0002_outstanding_hiroim",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,12 +4,14 @@
|
||||
"version": "3.0.0alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "bun --bun vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "bun test",
|
||||
"test:watch": "bun test --watch",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
@@ -23,7 +25,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^22.19.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -35,7 +37,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
|
||||
31
scripts/analyze_top_users.sh
Executable file
31
scripts/analyze_top_users.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# analyze_top_users.sh
|
||||
# Analyzes the daily_completions table to find the top 10 anonymous IDs by completion count
|
||||
|
||||
# Set database path from argument or default to dev.db
|
||||
DB_PATH="${1:-dev.db}"
|
||||
|
||||
# Check if database file exists
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "Error: Database file not found: $DB_PATH"
|
||||
echo "Usage: $0 [database_path]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the analysis query
|
||||
sqlite3 "$DB_PATH" <<EOF
|
||||
.mode column
|
||||
.headers on
|
||||
.width 36 16 16 17
|
||||
|
||||
SELECT
|
||||
anonymous_id,
|
||||
COUNT(*) as completion_count,
|
||||
MIN(date) as first_completion,
|
||||
MAX(date) as latest_completion
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id
|
||||
ORDER BY completion_count DESC
|
||||
LIMIT 10;
|
||||
EOF
|
||||
20
scripts/clear-today-verse.sh
Executable file
20
scripts/clear-today-verse.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Clear today's verse from daily_verses table
|
||||
DB_PATH="dev.db"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Deleting verse for date: $TODAY"
|
||||
|
||||
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Successfully deleted verse for $TODAY"
|
||||
|
||||
# Show remaining verses in table
|
||||
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
|
||||
echo "Remaining verses in database: $COUNT"
|
||||
else
|
||||
echo "✗ Failed to delete verse"
|
||||
exit 1
|
||||
fi
|
||||
34
scripts/daily_completions_report.sh
Executable file
34
scripts/daily_completions_report.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
DB_PATH="./local.db"
|
||||
|
||||
# Check if database exists
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "Error: Database not found at $DB_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Query for daily completions on 2026-02-01 with ranking
|
||||
echo "Daily Completions for 2026-02-01"
|
||||
echo "================================="
|
||||
echo ""
|
||||
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
|
||||
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
|
||||
|
||||
# Execute query with custom column mode
|
||||
sqlite3 "$DB_PATH" <<SQL
|
||||
.mode column
|
||||
.headers off
|
||||
.width 12 10 6
|
||||
SELECT
|
||||
SUBSTR(anonymous_id, 1, 10) as anon_id,
|
||||
guess_count,
|
||||
RANK() OVER (ORDER BY guess_count ASC) as rank
|
||||
FROM daily_completions
|
||||
WHERE date = '2026-02-01'
|
||||
ORDER BY rank, guess_count;
|
||||
SQL
|
||||
|
||||
echo ""
|
||||
echo "Total entries:"
|
||||
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"
|
||||
41
scripts/dedup-completions.ts
Normal file
41
scripts/dedup-completions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import path from 'path';
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice(5) : dbUrl;
|
||||
const db = new Database(path.resolve(dbPath));
|
||||
|
||||
const duplicates = db.query(`
|
||||
SELECT anonymous_id, date, COUNT(*) as count
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id, date
|
||||
HAVING count > 1
|
||||
`).all() as { anonymous_id: string; date: string; count: number }[];
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log('No duplicates found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${duplicates.length} duplicate group(s):`);
|
||||
|
||||
const deleteStmt = db.query(`
|
||||
DELETE FROM daily_completions
|
||||
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||
AND id NOT IN (
|
||||
SELECT id FROM daily_completions
|
||||
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
`);
|
||||
|
||||
for (const { anonymous_id, date, count } of duplicates) {
|
||||
deleteStmt.run({ $anonymous_id: anonymous_id, $date: date });
|
||||
console.log(` ${anonymous_id} / ${date}: kept earliest, deleted ${count - 1} row(s) (had ${count})`);
|
||||
}
|
||||
|
||||
console.log('Done.');
|
||||
db.close();
|
||||
75
scripts/deduplicate-completions.ts
Normal file
75
scripts/deduplicate-completions.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import Database from 'bun:sqlite';
|
||||
|
||||
// Database path - adjust if your database is located elsewhere
|
||||
const dbPath = Bun.env.DATABASE_URL || './local.db';
|
||||
console.log(`Connecting to database: ${dbPath}`);
|
||||
const db = new Database(dbPath);
|
||||
|
||||
interface DuplicateGroup {
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface Completion {
|
||||
id: string;
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
guess_count: number;
|
||||
completed_at: number;
|
||||
}
|
||||
|
||||
console.log('Finding duplicates...\n');
|
||||
|
||||
// Find all (anonymous_id, date) pairs with duplicates
|
||||
const duplicatesQuery = db.query<DuplicateGroup, []>(`
|
||||
SELECT anonymous_id, date, COUNT(*) as count
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id, date
|
||||
HAVING count > 1
|
||||
`);
|
||||
|
||||
const duplicates = duplicatesQuery.all();
|
||||
console.log(`Found ${duplicates.length} duplicate groups\n`);
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log('No duplicates to clean up!');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
|
||||
// Process each duplicate group
|
||||
for (const dup of duplicates) {
|
||||
// Get all completions for this (anonymous_id, date) pair
|
||||
const completionsQuery = db.query<Completion, [string, string]>(`
|
||||
SELECT id, anonymous_id, date, guess_count, completed_at
|
||||
FROM daily_completions
|
||||
WHERE anonymous_id = ? AND date = ?
|
||||
ORDER BY completed_at ASC
|
||||
`);
|
||||
|
||||
const completions = completionsQuery.all(dup.anonymous_id, dup.date);
|
||||
console.log(` ${dup.anonymous_id} on ${dup.date}: ${completions.length} entries`);
|
||||
|
||||
// Keep the first (earliest completion), delete the rest
|
||||
const toKeep = completions[0];
|
||||
const toDelete = completions.slice(1);
|
||||
|
||||
console.log(` Keeping: ${toKeep.id} (completed at ${new Date(toKeep.completed_at * 1000).toISOString()})`);
|
||||
|
||||
const deleteQuery = db.query('DELETE FROM daily_completions WHERE id = ?');
|
||||
|
||||
for (const comp of toDelete) {
|
||||
console.log(` Deleting: ${comp.id} (completed at ${new Date(comp.completed_at * 1000).toISOString()})`);
|
||||
deleteQuery.run(comp.id);
|
||||
totalDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Deduplication complete!`);
|
||||
console.log(`Total records deleted: ${totalDeleted}`);
|
||||
console.log(`Unique completions preserved: ${duplicates.length}`);
|
||||
|
||||
db.close();
|
||||
26
scripts/seed-fake-completions.sh
Executable file
26
scripts/seed-fake-completions.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Seed the database with 10 fake completions with random anonymous_ids
|
||||
# Useful for testing streak percentile and stats features
|
||||
|
||||
DB_PATH="dev.db"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
NOW=$(date +%s)
|
||||
|
||||
echo "Seeding 10 fake completions for date: $TODAY"
|
||||
|
||||
for i in {1..50}; do
|
||||
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
ANON_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
GUESS_COUNT=$(( (RANDOM % 6) + 1 )) # 1–6 guesses
|
||||
|
||||
sqlite3 "$DB_PATH" "
|
||||
INSERT OR IGNORE INTO daily_completions (id, anonymous_id, date, guess_count, completed_at)
|
||||
VALUES ('$ID', '$ANON_ID', '$TODAY', $GUESS_COUNT, $NOW);
|
||||
"
|
||||
|
||||
echo " [$i] anon=$ANON_ID guesses=$GUESS_COUNT"
|
||||
done
|
||||
|
||||
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '$TODAY';")
|
||||
echo "✓ Done. Total completions for $TODAY: $TOTAL"
|
||||
41
scripts/test-share-text.ts
Normal file
41
scripts/test-share-text.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||
import { generateShareText } from '../src/lib/utils/share';
|
||||
import { bibleBooks } from '../src/lib/types/bible';
|
||||
|
||||
const NUM_VERSES = 10;
|
||||
|
||||
for (let i = 0; i < NUM_VERSES; i++) {
|
||||
const verse = await fetchRandomVerse();
|
||||
|
||||
// Build a fake "solved in N guesses" scenario with some wrong guesses first
|
||||
const correctBook = bibleBooks.find((b) => b.id === verse.bookId)!;
|
||||
const wrongBook = bibleBooks.find((b) => b.id !== verse.bookId)!;
|
||||
const guessCount = Math.floor(Math.random() * 5) + 1;
|
||||
const guesses = [
|
||||
...Array(guessCount - 1).fill(null).map(() => ({
|
||||
book: wrongBook,
|
||||
testamentMatch: wrongBook.testament === correctBook.testament,
|
||||
sectionMatch: wrongBook.section === correctBook.section,
|
||||
adjacent: Math.abs(wrongBook.order - correctBook.order) === 1,
|
||||
})),
|
||||
{ book: correctBook, testamentMatch: true, sectionMatch: true, adjacent: false },
|
||||
];
|
||||
|
||||
const fakeStreak = Math.random() > 0.5 ? Math.floor(Math.random() * 14) + 2 : 0;
|
||||
|
||||
const shareText = generateShareText({
|
||||
guesses,
|
||||
correctBookId: verse.bookId,
|
||||
dailyVerseDate: new Date().toISOString().slice(0, 10),
|
||||
chapterCorrect: guessCount === 1 && Math.random() > 0.5,
|
||||
isLoggedIn: Math.random() > 0.5,
|
||||
streak: fakeStreak > 0 ? fakeStreak : undefined,
|
||||
origin: 'https://bibdle.com',
|
||||
verseText: verse.verseText,
|
||||
});
|
||||
|
||||
console.log(`\n── Verse ${i + 1}: ${verse.reference} ──`);
|
||||
console.log(`RAW: ${verse.verseText}`);
|
||||
console.log('─'.repeat(40));
|
||||
console.log(shareText);
|
||||
}
|
||||
10
scripts/test-verse-snippets.ts
Normal file
10
scripts/test-verse-snippets.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||
import { getVerseSnippet } from '../src/lib/utils/share';
|
||||
|
||||
const NUM_VERSES = 10;
|
||||
|
||||
for (let i = 0; i < NUM_VERSES; i++) {
|
||||
const verse = await fetchRandomVerse();
|
||||
|
||||
console.log(getVerseSnippet(verse.verseText));
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
4
src/lib/assets/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;
|
||||
}
|
||||
|
||||
// Generate 6 random chapter options including the correct one
|
||||
// Generate 4 random chapter options including the correct one
|
||||
function generateChapterOptions(
|
||||
correctChapter: number,
|
||||
totalChapters: number,
|
||||
@@ -98,14 +98,14 @@
|
||||
const options = new Set<number>();
|
||||
options.add(correctChapter);
|
||||
|
||||
if (totalChapters >= 6) {
|
||||
while (options.size < 6) {
|
||||
if (totalChapters >= 4) {
|
||||
while (options.size < 4) {
|
||||
const randomChapter =
|
||||
Math.floor(Math.random() * totalChapters) + 1;
|
||||
options.add(randomChapter);
|
||||
}
|
||||
} else {
|
||||
while (options.size < 6) {
|
||||
while (options.size < 4) {
|
||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||
options.add(randomChapter);
|
||||
}
|
||||
@@ -167,18 +167,18 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
||||
Guess the chapter for an even higher grade
|
||||
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||
Bonus Challenge
|
||||
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||
>— guess the chapter for an even higher grade</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
||||
>
|
||||
{#each chapterOptions as chapter}
|
||||
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||
{#each chapterOptions as chapter (chapter)}
|
||||
<button
|
||||
onclick={() => handleChapterSelect(chapter)}
|
||||
disabled={hasAnswered}
|
||||
@@ -193,8 +193,8 @@
|
||||
? isCorrect
|
||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||
: "bg-red-400 text-white border-red-500"
|
||||
: "bg-white/30 text-gray-400 border-gray-300 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/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
|
||||
: "bg-white/80 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 text-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 hover:shadow-md cursor-pointer"
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<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()}
|
||||
</div>
|
||||
|
||||
@@ -1,88 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let timeUntilNext = $state("");
|
||||
let intervalId: number | null = null;
|
||||
let timeUntilNext = $state("");
|
||||
let newVerseReady = $state(false);
|
||||
let intervalId: number | null = null;
|
||||
let targetTime = 0;
|
||||
|
||||
function calculateTimeUntilFivePM(): string {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
function initTarget() {
|
||||
const target = new Date();
|
||||
target.setHours(0, 0, 0, 0);
|
||||
if (Date.now() >= target.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
targetTime = target.getTime();
|
||||
}
|
||||
|
||||
// Set target to 5:00 PM today
|
||||
target.setHours(17, 0, 0, 0);
|
||||
function updateTimer() {
|
||||
const diff = targetTime - Date.now();
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (diff <= 0) {
|
||||
newVerseReady = true;
|
||||
timeUntilNext = "";
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = target.getTime() - now.getTime();
|
||||
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);
|
||||
|
||||
if (diff <= 0) {
|
||||
return "00:00:00";
|
||||
}
|
||||
timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
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);
|
||||
onMount(() => {
|
||||
initTarget();
|
||||
updateTimer();
|
||||
intervalId = window.setInterval(updateTimer, 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);
|
||||
|
||||
// If it's already past midnight, set target to tomorrow midnight
|
||||
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 updateTimer() {
|
||||
timeUntilNext = calculateTimeUntilMidnight();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateTimer();
|
||||
intervalId = window.setInterval(updateTimer, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<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"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
|
||||
Next Verse In
|
||||
</p>
|
||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full flex flex-col flex-1">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mt-2"
|
||||
>
|
||||
(refresh page to see the new verse)
|
||||
</p>
|
||||
{:else}
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p
|
||||
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
|
||||
>
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||
</script>
|
||||
|
||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 bg-white/50 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 font-bold">
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300 font-bold"
|
||||
>
|
||||
A project by George Powell & Silent Summit Co.
|
||||
</p>
|
||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
@@ -32,19 +35,37 @@
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Bluesky"
|
||||
data-umami-event="Bluesky clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400"></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
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Email clicked")}
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700"
|
||||
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
import { browser } from "$app/environment";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
|
||||
let { anonymousId }: { anonymousId: string | null } = $props();
|
||||
|
||||
let seeding = $state(false);
|
||||
|
||||
async function seedHistory(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() {
|
||||
if (!browser) return;
|
||||
// Clear all bibdle-related localStorage items
|
||||
@@ -86,4 +110,21 @@
|
||||
>
|
||||
Clear LocalStorage
|
||||
</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>
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||
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 {
|
||||
guesses,
|
||||
correctBookId,
|
||||
@@ -44,16 +32,27 @@
|
||||
(correctBook?.section === "Pauline Epistles" ||
|
||||
correctBook?.section === "General Epistles") &&
|
||||
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 (
|
||||
correctIsEpistlesWithNumber &&
|
||||
guessStartsWithNumber &&
|
||||
guessIsEpistlesWithNumber &&
|
||||
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":
|
||||
return (
|
||||
guess.book.testament.charAt(0).toUpperCase() +
|
||||
@@ -67,63 +66,50 @@
|
||||
|
||||
{#if !hasGuesses}
|
||||
<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
|
||||
</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
|
||||
get clues to tell you if your guess is close or not. Green means the
|
||||
category is correct; red means wrong.
|
||||
get clues to help you after each guess.
|
||||
</p>
|
||||
</Container>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<!-- Column Headers -->
|
||||
<div
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||
>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
>
|
||||
Book
|
||||
</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"
|
||||
>
|
||||
Testament
|
||||
</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
|
||||
</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
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<!-- 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 -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.testamentMatch,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "testament")}</span
|
||||
@@ -136,7 +122,7 @@
|
||||
guess.sectionMatch,
|
||||
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"
|
||||
>{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(
|
||||
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"
|
||||
>{getBoxContent(guess, "firstLetter")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Book Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
|
||||
guess.book.id === correctBookId,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,241 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface ImposterData {
|
||||
verses: string[];
|
||||
refs: string[];
|
||||
imposterIndex: number;
|
||||
}
|
||||
interface ImposterData {
|
||||
verses: string[];
|
||||
refs: string[];
|
||||
imposterIndex: number;
|
||||
}
|
||||
|
||||
let data: ImposterData | null = null;
|
||||
let clicked: boolean[] = [];
|
||||
let gameOver = false;
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
let data: ImposterData | null = $state(null);
|
||||
let clicked: boolean[] = $state([]);
|
||||
let gameOver = $state(false);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
async function loadGame() {
|
||||
try {
|
||||
const res = await fetch("/api/imposter");
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
data = (await res.json()) as ImposterData;
|
||||
clicked = new Array(data.verses.length).fill(false);
|
||||
gameOver = false;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unknown error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function loadGame() {
|
||||
try {
|
||||
const res = await fetch("/api/imposter");
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
data = (await res.json()) as ImposterData;
|
||||
clicked = new Array(data.verses.length).fill(false);
|
||||
gameOver = false;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unknown error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(index: number) {
|
||||
if (gameOver || !data || clicked[index]) return;
|
||||
clicked[index] = true;
|
||||
if (index !== data.imposterIndex) {
|
||||
clicked[data.imposterIndex] = true;
|
||||
}
|
||||
gameOver = true;
|
||||
}
|
||||
function handleClick(index: number) {
|
||||
if (gameOver || !data || clicked[index]) return;
|
||||
clicked[index] = true;
|
||||
if (index !== data.imposterIndex) {
|
||||
clicked[data.imposterIndex] = true;
|
||||
}
|
||||
gameOver = true;
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
loading = true;
|
||||
error = null;
|
||||
data = null;
|
||||
loadGame();
|
||||
}
|
||||
function newGame() {
|
||||
loading = true;
|
||||
error = null;
|
||||
data = null;
|
||||
loadGame();
|
||||
}
|
||||
|
||||
onMount(loadGame);
|
||||
onMount(loadGame);
|
||||
|
||||
function formatVerse(verse: string): string {
|
||||
let formatted = verse;
|
||||
function formatVerse(verse: string): string {
|
||||
let formatted = verse;
|
||||
|
||||
// Handle unbalanced opening/closing punctuation
|
||||
const pairs: [string, string][] = [
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
["\u201C", "\u201D"], // \u201C
|
||||
["\u2018", "\u2019"], // \u2018
|
||||
];
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||
formatted += "..." + close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||
formatted = open + "..." + formatted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Handle unbalanced opening/closing punctuation
|
||||
const pairs: [string, string][] = [
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
["\u201C", "\u201D"], // \u201C
|
||||
["\u2018", "\u2019"], // \u2018
|
||||
];
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||
formatted += "..." + close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||
formatted = open + "..." + formatted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-z]/.test(formatted)) {
|
||||
formatted = "..." + formatted;
|
||||
}
|
||||
formatted = formatted.replace(/[,:;-—]$/, "...");
|
||||
return formatted;
|
||||
}
|
||||
if (/^[a-z]/.test(formatted)) {
|
||||
formatted = "..." + formatted;
|
||||
}
|
||||
// Replace trailing punctuation with ellipsis
|
||||
// Preserve closing quotes/brackets that may have been added
|
||||
formatted = formatted.replace(
|
||||
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||
"...$1",
|
||||
);
|
||||
return formatted;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="imposter-game">
|
||||
{#if loading}
|
||||
<p class="loading">Loading verses...</p>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Error: {error}</p>
|
||||
<button on:click={newGame}>Retry</button>
|
||||
</div>
|
||||
{:else if data}
|
||||
<!-- <div class="instructions">
|
||||
{#if loading}
|
||||
<p class="loading">Loading verses...</p>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Error: {error}</p>
|
||||
<button onclick={newGame}>Retry</button>
|
||||
</div>
|
||||
{:else if data}
|
||||
<!-- <div class="instructions">
|
||||
<p>Click the verse that doesn't belong (from a different book).</p>
|
||||
</div> -->
|
||||
<div class="verses">
|
||||
{#each data.verses as verse, i}
|
||||
<div class="verse-item">
|
||||
<button
|
||||
class="verse-button"
|
||||
class:clicked={clicked[i]}
|
||||
class:correct={clicked[i] && i === data.imposterIndex}
|
||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||
on:click={() => handleClick(i)}
|
||||
disabled={gameOver}
|
||||
>
|
||||
{formatVerse(verse)}
|
||||
</button>
|
||||
{#if gameOver}
|
||||
<div class="ref">{data.refs[i]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if gameOver}
|
||||
<div class="result">
|
||||
<button on:click={newGame}>New Game</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="verses">
|
||||
{#each data.verses as verse, i}
|
||||
<div class="verse-item">
|
||||
<button
|
||||
class="verse-button"
|
||||
class:clicked={clicked[i]}
|
||||
class:correct={clicked[i] && i === data.imposterIndex}
|
||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||
onclick={() => handleClick(i)}
|
||||
disabled={gameOver}
|
||||
>
|
||||
{formatVerse(verse)}
|
||||
</button>
|
||||
{#if gameOver}
|
||||
<div class="ref">{data.refs[i]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if gameOver}
|
||||
<div class="result">
|
||||
<button onclick={newGame}>New Game</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.imposter-game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.imposter-game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
/*.instructions {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
}*/
|
||||
|
||||
.verses {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.verses {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.verse-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.verse-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.verse-button {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
border: 3px solid #ddd;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 100px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.verse-button {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
border: 3px solid #ddd;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 100px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||
border-color: #007bff;
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||
border-color: #007bff;
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
.verse-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.verse-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.verse-button.clicked {
|
||||
cursor: default;
|
||||
}
|
||||
.verse-button.clicked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.correct {
|
||||
background: #d4edda !important;
|
||||
border-color: #28a745 !important;
|
||||
color: #155724;
|
||||
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
.correct {
|
||||
background: #d4edda !important;
|
||||
border-color: #28a745 !important;
|
||||
color: #155724;
|
||||
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.wrong {
|
||||
background: #f8d7da !important;
|
||||
border-color: #dc3545 !important;
|
||||
color: #721c24;
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
.wrong {
|
||||
background: #f8d7da !important;
|
||||
border-color: #dc3545 !important;
|
||||
color: #721c24;
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.ref {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
.ref {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.verse-button.correct ~ .ref {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
.verse-button.correct ~ .ref {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verse-button.wrong ~ .ref {
|
||||
color: #dc3545;
|
||||
}
|
||||
.verse-button.wrong ~ .ref {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.result {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result button,
|
||||
.error button {
|
||||
padding: 0.75rem 2rem;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.result button,
|
||||
.error button {
|
||||
padding: 0.75rem 2rem;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.result button:hover,
|
||||
.error button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.result button:hover,
|
||||
.error button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,154 @@
|
||||
<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) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
type SimpleGroup = { books: BibleBook[] };
|
||||
|
||||
type TestamentGroup = {
|
||||
testament: Testament;
|
||||
label: string;
|
||||
books: BibleBook[];
|
||||
};
|
||||
|
||||
type SectionGroup = {
|
||||
testament: Testament;
|
||||
testamentLabel: string;
|
||||
showTestamentHeader: boolean;
|
||||
section: BibleSection;
|
||||
books: BibleBook[];
|
||||
};
|
||||
|
||||
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||
const sorted = [...filteredBooks].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
return { books: sorted };
|
||||
});
|
||||
|
||||
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||
const old = filteredBooks
|
||||
.filter((b) => b.testament === "old")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const newT = filteredBooks
|
||||
.filter((b) => b.testament === "new")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const groups: TestamentGroup[] = [];
|
||||
if (old.length > 0) {
|
||||
groups.push({ testament: "old", label: "Old Testament", books: old });
|
||||
}
|
||||
if (newT.length > 0) {
|
||||
groups.push({ testament: "new", label: "New Testament", books: newT });
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||
const seenKeys: Record<string, true> = {};
|
||||
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
|
||||
|
||||
for (const book of bibleBooks) {
|
||||
const key = `${book.testament}:${book.section}`;
|
||||
if (!seenKeys[key]) {
|
||||
seenKeys[key] = true;
|
||||
orderedPairs.push({ testament: book.testament, section: book.section });
|
||||
}
|
||||
}
|
||||
|
||||
const groups: SectionGroup[] = [];
|
||||
let lastTestament: Testament | null = null;
|
||||
|
||||
for (const pair of orderedPairs) {
|
||||
const books = filteredBooks.filter(
|
||||
(b) => b.testament === pair.testament && b.section === pair.section
|
||||
);
|
||||
if (books.length === 0) continue;
|
||||
|
||||
const showTestamentHeader = pair.testament !== lastTestament;
|
||||
lastTestament = pair.testament;
|
||||
|
||||
groups.push({
|
||||
testament: pair.testament,
|
||||
testamentLabel:
|
||||
pair.testament === "old" ? "Old Testament" : "New Testament",
|
||||
showTestamentHeader,
|
||||
section: pair.section,
|
||||
books,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// First book in display order for Enter key submission
|
||||
const firstBookId = $derived.by<string | null>(() => {
|
||||
if (filteredBooks.length === 0) return null;
|
||||
if (displayMode === "simple") {
|
||||
return simpleGroup.books[0]?.id ?? null;
|
||||
}
|
||||
if (displayMode === "testament") {
|
||||
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||
}
|
||||
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
||||
submitGuess(filteredBooks[0].id);
|
||||
if (e.key === "Enter" && firstBookId) {
|
||||
submitGuess(firstBookId);
|
||||
}
|
||||
}
|
||||
|
||||
const showBanner = $derived(guessCount >= 3);
|
||||
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||
</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">
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -35,13 +160,13 @@
|
||||
<input
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 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}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<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 = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
@@ -51,6 +176,7 @@
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -62,31 +188,122 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchQuery && filteredBooks.length > 0}
|
||||
<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)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-500'
|
||||
: ''}">{book.name}</span
|
||||
{#if displayMode === "simple"}
|
||||
{#each simpleGroup.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span class="ml-auto text-sm opacity-75"
|
||||
>({book.testament.toUpperCase()})</span
|
||||
<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"
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||
<li role="presentation">
|
||||
{#if group.showTestamentHeader}
|
||||
<div
|
||||
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{group.testamentLabel}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{group.section}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 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}
|
||||
</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,38 +1,87 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import Container from "./Container.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { fade } from "svelte/transition";
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
let {
|
||||
data,
|
||||
isWon,
|
||||
blurChapter = false,
|
||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let displayReference = $derived(
|
||||
blurChapter
|
||||
? dailyVerse.reference
|
||||
.replace(/^Psalms /, "Psalm ")
|
||||
.replace(/\s(\d+):/, " ?:")
|
||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
||||
);
|
||||
let displayVerseText = $derived(
|
||||
dailyVerse.verseText
|
||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||
.replace(/[,:;-—]$/, "...")
|
||||
);
|
||||
let {
|
||||
data,
|
||||
isWon,
|
||||
blurChapter = false,
|
||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let displayReference = $derived(
|
||||
blurChapter
|
||||
? dailyVerse.reference
|
||||
.replace(/^Psalms /, "Psalm ")
|
||||
.replace(/\s(\d+):/, " ?:")
|
||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||
);
|
||||
let displayVerseText = $derived(
|
||||
dailyVerse.verseText
|
||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||
.replace(/[,:;-—]$/, "..."),
|
||||
);
|
||||
|
||||
let showReference = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
// Delay showing reference until GuessesTable animation completes
|
||||
$effect(() => {
|
||||
if (!isWon) {
|
||||
showReference = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user already won today (page reload case)
|
||||
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
const alreadyWonToday =
|
||||
browser && localStorage.getItem(winTrackedKey) === "true";
|
||||
|
||||
if (alreadyWonToday) {
|
||||
// User already won and is refreshing - show immediately
|
||||
showReference = true;
|
||||
} else {
|
||||
// User just won this session - delay for animation
|
||||
const animationDelay = 1800;
|
||||
const timeoutId = setTimeout(() => {
|
||||
showReference = true;
|
||||
}, animationDelay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
function copyVerse() {
|
||||
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||
copied = true;
|
||||
(window as any).rybbit?.event("Copy Verse");
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
{#if isWon}
|
||||
<p
|
||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
<Container
|
||||
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||
>
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||
style="max-height: {showReference ? '200px' : '0px'};"
|
||||
>
|
||||
{#if showReference}
|
||||
<p
|
||||
transition:fade={{ duration: 400 }}
|
||||
class="text-center text-lg! big-text text-green-600! dark:text-green-400! font-bold mt-8 bg-white/70 dark:bg-black/50 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||
import {
|
||||
getBookById,
|
||||
toOrdinal,
|
||||
getNextGradeMessage,
|
||||
} from "$lib/utils/game";
|
||||
import { onMount } from "svelte";
|
||||
getVerseSnippet,
|
||||
shareResult,
|
||||
copyToClipboard as clipboardCopy,
|
||||
} from "$lib/utils/share";
|
||||
import Container from "./Container.svelte";
|
||||
import CountdownTimer from "./CountdownTimer.svelte";
|
||||
import StreakCounter from "./StreakCounter.svelte";
|
||||
import ChapterGuess from "./ChapterGuess.svelte";
|
||||
|
||||
interface StatsData {
|
||||
@@ -25,7 +26,6 @@
|
||||
}
|
||||
|
||||
let {
|
||||
grade,
|
||||
statsData,
|
||||
correctBookId,
|
||||
handleShare,
|
||||
@@ -35,6 +35,24 @@
|
||||
guessCount,
|
||||
reference,
|
||||
onChapterGuessCompleted,
|
||||
shareText,
|
||||
verseText,
|
||||
streak = 0,
|
||||
streakPercentile = null,
|
||||
}: {
|
||||
statsData: StatsData | null;
|
||||
correctBookId: string;
|
||||
handleShare: () => void;
|
||||
copyToClipboard: () => void;
|
||||
copied: boolean;
|
||||
statsSubmitted: boolean;
|
||||
guessCount: number;
|
||||
reference: string;
|
||||
onChapterGuessCompleted: () => void;
|
||||
shareText: string;
|
||||
verseText: string;
|
||||
streak?: number;
|
||||
streakPercentile?: number | null;
|
||||
} = $props();
|
||||
|
||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||
@@ -42,6 +60,24 @@
|
||||
typeof navigator !== "undefined" && "share" in navigator,
|
||||
);
|
||||
let copySuccess = $state(false);
|
||||
let bubbleCopied = $state(false);
|
||||
let copyTracked = $state(false);
|
||||
let showSnippetOption = $state(false);
|
||||
let includeSnippet = $state(false);
|
||||
|
||||
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
|
||||
const congratulationsMessages: WeightedMessage[] = [
|
||||
@@ -57,9 +93,9 @@
|
||||
if (guessCount === 1) {
|
||||
const n = Math.random();
|
||||
if (n < 0.99) {
|
||||
return "🌟 First try! 🌟";
|
||||
return "First try!";
|
||||
} else {
|
||||
return "🗣️ Axios! 🗣️";
|
||||
return "Axios!";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,66 +122,27 @@
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<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">
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
||||
</p>
|
||||
<p class="text-lg sm:text-xl md:text-2xl mt-4">
|
||||
You guessed correctly after {guessCount}
|
||||
{guessCount === 1 ? "guess" : "guesses"}.
|
||||
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
|
||||
>{grade}</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center mt-6">
|
||||
{#if hasWebShare}
|
||||
<!-- 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
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
|
||||
{#if guessCount !== 1}
|
||||
<p class="pt-6 big-text text-gray-700!">
|
||||
{getNextGradeMessage(guessCount)}
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||
>{bookName}</span
|
||||
>.
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-lg sm:text-xl md:text-2xl">
|
||||
You guessed correctly after {guessCount}
|
||||
{guessCount === 1 ? "guess" : "guesses"}.
|
||||
</p>
|
||||
<!-- {#if streak >= 7}
|
||||
<p
|
||||
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||
>
|
||||
Thank you for making BIBDLE part of your daily routine!
|
||||
</p>
|
||||
{/if} -->
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- S++ Bonus Challenge for first try -->
|
||||
@@ -157,12 +154,21 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<CountdownTimer />
|
||||
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||
<div class="flex-2 min-w-0 flex flex-col">
|
||||
<CountdownTimer />
|
||||
</div>
|
||||
{#if streak > 0}
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<StreakCounter {streak} {streakPercentile} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Statistics Display -->
|
||||
{#if statsData}
|
||||
<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
|
||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||
@@ -171,7 +177,7 @@
|
||||
<!-- Solve Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<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}
|
||||
</div>
|
||||
@@ -184,7 +190,7 @@
|
||||
<!-- Guess Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<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)}
|
||||
</div>
|
||||
@@ -206,7 +212,7 @@
|
||||
<!-- Average Column -->
|
||||
<div class="flex flex-col">
|
||||
<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}
|
||||
</div>
|
||||
@@ -220,11 +226,99 @@
|
||||
</Container>
|
||||
{:else if !statsSubmitted}
|
||||
<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>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||
<div class="big-text font-black! text-center">Share your result</div>
|
||||
<div class="chat-window">
|
||||
<!-- Received bubble: primary action (share / copy) -->
|
||||
<div class="bubble-wrapper received-wrapper">
|
||||
<button
|
||||
class="bubble bubble-received"
|
||||
class:success={copySuccess}
|
||||
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
||||
data-umami-event={hasWebShare
|
||||
? "Share"
|
||||
: "Copy to Clipboard"}
|
||||
onclick={() => {
|
||||
if (hasWebShare) {
|
||||
(window as any).rybbit?.event("Share");
|
||||
shareResult(effectiveShareText);
|
||||
} else {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event(
|
||||
"Copy to Clipboard",
|
||||
);
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
copySuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if hasWebShare}
|
||||
📤 Tap here to share
|
||||
{:else if copySuccess}
|
||||
✅ Copied!
|
||||
{:else}
|
||||
📋 Copy to clipboard
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sent bubble: share text preview -->
|
||||
<div class="bubble-wrapper">
|
||||
<button
|
||||
class="bubble bubble-sent"
|
||||
aria-label="Copy to clipboard"
|
||||
data-umami-event="Copy to Clipboard"
|
||||
onclick={() => {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event("Copy to Clipboard");
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
showSnippetOption = true;
|
||||
bubbleCopied = true;
|
||||
setTimeout(() => {
|
||||
bubbleCopied = false;
|
||||
}, 2000);
|
||||
}}>{effectiveShareText}</button
|
||||
>
|
||||
{#if hasWebShare}
|
||||
<span class="copy-hint"
|
||||
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="copy-hint"
|
||||
>{bubbleCopied ? "copied!" : ""}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSnippetOption}
|
||||
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
||||
<span class="snippet-label">Show verse snippet in share?</span>
|
||||
<button
|
||||
class="snippet-toggle"
|
||||
class:on={includeSnippet}
|
||||
onclick={() => (includeSnippet = !includeSnippet)}
|
||||
aria-pressed={includeSnippet}
|
||||
aria-label="Show snippet in share"
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -239,7 +333,269 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
:global(.fade-in) {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* ── Share card ── */
|
||||
.share-card {
|
||||
background: oklch(94% 0.028 298.626);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.share-card {
|
||||
background: oklch(22% 0.025 298.626);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.share-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Chat window ── */
|
||||
.chat-window {
|
||||
--sent-color: #0b93f6;
|
||||
--received-color: #3a3a3c;
|
||||
--bg: oklch(94% 0.028 298.626);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.5rem 0;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-window {
|
||||
--bg: oklch(22% 0.025 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bubble wrappers ── */
|
||||
.bubble-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.received-wrapper {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Shared bubble base ── */
|
||||
.bubble {
|
||||
position: relative;
|
||||
max-width: 255px;
|
||||
margin-bottom: 0;
|
||||
padding: 10px 20px;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
border-radius: 25px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 80ms ease,
|
||||
transform 80ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Sent bubble (share text preview) ── */
|
||||
.bubble-sent {
|
||||
color: white;
|
||||
background: var(--sent-color);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.bubble-sent:hover {
|
||||
background-color: #2ea8ff;
|
||||
transform: rotate(-2deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.bubble-sent:hover::before {
|
||||
background-color: #2ea8ff;
|
||||
}
|
||||
|
||||
.bubble-sent:active {
|
||||
background-color: #0878d4;
|
||||
transform: rotate(-2deg) scale(0.97);
|
||||
}
|
||||
|
||||
.bubble-sent:active::before {
|
||||
background-color: #0878d4;
|
||||
}
|
||||
|
||||
/* Sent tail: bottom-right */
|
||||
.bubble-sent::before,
|
||||
.bubble-sent::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 25px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bubble-sent::before {
|
||||
width: 20px;
|
||||
right: -7px;
|
||||
background-color: var(--sent-color);
|
||||
border-bottom-left-radius: 16px 14px;
|
||||
}
|
||||
|
||||
.bubble-sent::after {
|
||||
width: 26px;
|
||||
right: -26px;
|
||||
border-bottom-left-radius: 10px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Received bubble (action button) ── */
|
||||
.bubble-received {
|
||||
color: #f5f5f7;
|
||||
background: var(--received-color);
|
||||
transform: rotate(2deg);
|
||||
padding: 14px 24px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
min-width: 14rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bubble-received:hover {
|
||||
background-color: #4a4a4e;
|
||||
transform: rotate(2deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.bubble-received:hover::before {
|
||||
background-color: #4a4a4e;
|
||||
}
|
||||
|
||||
.bubble-received:active {
|
||||
background-color: #2a2a2c;
|
||||
transform: rotate(2deg) scale(0.97);
|
||||
}
|
||||
|
||||
.bubble-received:active::before {
|
||||
background-color: #2a2a2c;
|
||||
}
|
||||
|
||||
.bubble-received.success {
|
||||
background: #c7f7d4;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
/* Received tail: bottom-left (mirror of sent) */
|
||||
.bubble-received::before,
|
||||
.bubble-received::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 25px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bubble-received::before {
|
||||
width: 20px;
|
||||
left: -7px;
|
||||
background-color: var(--received-color);
|
||||
border-bottom-right-radius: 16px 14px;
|
||||
}
|
||||
|
||||
.bubble-received::after {
|
||||
width: 26px;
|
||||
left: -26px;
|
||||
border-bottom-right-radius: 10px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.bubble-received.success::before {
|
||||
background-color: #c7f7d4;
|
||||
}
|
||||
|
||||
/* ── Copy hints ── */
|
||||
.copy-hint {
|
||||
font-size: 0.68rem;
|
||||
color: #444;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
padding-right: 32px;
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: right center;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.copy-hint {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Snippet toggle row ── */
|
||||
.snippet-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.snippet-label {
|
||||
font-size: 0.72rem;
|
||||
color: #666;
|
||||
letter-spacing: 0.01em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.snippet-label {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.snippet-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #ccc;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snippet-toggle.on {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.snippet-toggle.on .toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
export * from './utils/game';
|
||||
export { default as VerseDisplay } from './components/VerseDisplay.svelte';
|
||||
export { default as SearchInput } from './components/SearchInput.svelte';
|
||||
export { default as GuessesTable } from './components/GuessesTable.svelte';
|
||||
export { default as WinScreen } from './components/WinScreen.svelte';
|
||||
export { default as Feedback } from './components/Feedback.svelte';
|
||||
|
||||
144
src/lib/server/apple-auth.ts
Normal file
144
src/lib/server/apple-auth.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
|
||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||
|
||||
export function getAppleAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.APPLE_ID!,
|
||||
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/apple/callback`,
|
||||
response_type: 'code',
|
||||
response_mode: 'form_post',
|
||||
scope: 'name email',
|
||||
state
|
||||
});
|
||||
return `${APPLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function generateAppleClientSecret(): Promise<string> {
|
||||
const header = { alg: 'ES256', kid: Bun.env.APPLE_KEY_ID! };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: Bun.env.APPLE_TEAM_ID!,
|
||||
iat: now,
|
||||
exp: now + 86400 * 180,
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: Bun.env.APPLE_ID!
|
||||
};
|
||||
|
||||
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
||||
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Import PEM private key
|
||||
const pemBody = Bun.env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBuffer,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const signature = new Uint8Array(signatureBuffer);
|
||||
|
||||
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||
const encodedSignature = encodeBase64url(rawSignature);
|
||||
|
||||
return `${signingInput}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
|
||||
*/
|
||||
function derToRaw(der: Uint8Array): Uint8Array {
|
||||
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
|
||||
let offset = 2; // skip 0x30 and total length
|
||||
|
||||
// Read r
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const rLen = der[offset];
|
||||
offset++;
|
||||
let r = der.slice(offset, offset + rLen);
|
||||
offset += rLen;
|
||||
|
||||
// Read s
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const sLen = der[offset];
|
||||
offset++;
|
||||
let s = der.slice(offset, offset + sLen);
|
||||
|
||||
// Remove leading zero padding (DER uses it for positive sign)
|
||||
if (r.length === 33 && r[0] === 0) r = r.slice(1);
|
||||
if (s.length === 33 && s[0] === 0) s = s.slice(1);
|
||||
|
||||
// Pad to 32 bytes each
|
||||
const raw = new Uint8Array(64);
|
||||
raw.set(r, 32 - r.length);
|
||||
raw.set(s, 64 - s.length);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function exchangeAppleCode(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
id_token: string;
|
||||
}> {
|
||||
const clientSecret = await generateAppleClientSecret();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.APPLE_ID!,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
});
|
||||
|
||||
const response = await fetch(APPLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Apple token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Apple's id_token JWT payload without signature verification.
|
||||
* Safe because the token is received directly from Apple's token endpoint over TLS.
|
||||
*/
|
||||
export function decodeAppleIdToken(idToken: string): {
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: string;
|
||||
is_private_email?: string;
|
||||
} {
|
||||
const [, payloadB64] = idToken.split('.');
|
||||
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return payload;
|
||||
}
|
||||
116
src/lib/server/auth.test.ts
Normal file
116
src/lib/server/auth.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { testDb as db } from '$lib/server/db/test';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
|
||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export const sessionCookieName = 'auth-session';
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||
const token = encodeBase64url(bytes);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function createSession(token: string, userId: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const session: table.Session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
|
||||
};
|
||||
await db.insert(table.session).values(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function validateSessionToken(token: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
user: { id: table.user.id, email: table.user.email },
|
||||
session: table.session
|
||||
})
|
||||
.from(table.session)
|
||||
.innerJoin(table.user, eq(table.session.userId, table.user.id))
|
||||
.where(eq(table.session.id, sessionId));
|
||||
|
||||
if (!result) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
const { session, user } = result;
|
||||
|
||||
const sessionExpired = Date.now() >= session.expiresAt.getTime();
|
||||
if (sessionExpired) {
|
||||
await db.delete(table.session).where(eq(table.session.id, session.id));
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
|
||||
if (renewSession) {
|
||||
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
|
||||
await db
|
||||
.update(table.session)
|
||||
.set({ expiresAt: session.expiresAt })
|
||||
.where(eq(table.session.id, session.id));
|
||||
}
|
||||
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
|
||||
|
||||
export async function invalidateSession(sessionId: string) {
|
||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
||||
event.cookies.set(sessionCookieName, token, {
|
||||
expires: expiresAt,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
event.cookies.delete(sessionCookieName, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 4,
|
||||
timeCost: 3
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
|
||||
const user: table.User = {
|
||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
};
|
||||
await db.insert(table.user).values(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||
return user || null;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export async function validateSessionToken(token: string) {
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
user: { id: table.user.id, username: table.user.username },
|
||||
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId },
|
||||
session: table.session
|
||||
})
|
||||
.from(table.session)
|
||||
@@ -79,3 +79,83 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 4,
|
||||
timeCost: 3
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
|
||||
const user: table.User = {
|
||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
};
|
||||
await db.insert(table.user).values(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function getUserByAppleId(appleId: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||
if (!anonymousId || anonymousId === userId) return;
|
||||
|
||||
try {
|
||||
const { dailyCompletions } = await import('$lib/server/db/schema');
|
||||
|
||||
const anonCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const userDates = new Set(userCompletions.map((c) => c.date));
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const completion of anonCompletions) {
|
||||
if (!userDates.has(completion.date)) {
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: userId })
|
||||
.where(eq(dailyCompletions.id, completion.id));
|
||||
migrated++;
|
||||
} else {
|
||||
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id));
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||
} catch (error) {
|
||||
console.error('Error migrating anonymous stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/lib/server/daily-verse.ts
Normal file
33
src/lib/server/daily-verse.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
|
||||
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
throw new Error('Invalid date format');
|
||||
}
|
||||
|
||||
// If there's an existing verse for this date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse for this date
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: Bun.randomUUIDv7(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
if (!Bun.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = new Database(env.DATABASE_URL);
|
||||
const client = new Database(Bun.env.DATABASE_URL);
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
||||
export const user = sqliteTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
firstName: text('first_name'),
|
||||
lastName: text('last_name'),
|
||||
email: text('email').unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
appleId: text('apple_id').unique(),
|
||||
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||
});
|
||||
|
||||
export const session = sqliteTable('session', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -30,11 +36,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
||||
anonymousId: text('anonymous_id').notNull(),
|
||||
date: text('date').notNull(),
|
||||
guessCount: integer('guess_count').notNull(),
|
||||
guesses: text('guesses'), // nullable; only stored for logged-in users
|
||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => ({
|
||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
||||
dateIndex: index('date_idx').on(table.date),
|
||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
||||
}));
|
||||
}, (table) => [
|
||||
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||
index('date_idx').on(table.date),
|
||||
index('date_guess_idx').on(table.date, table.guessCount),
|
||||
// Ensures schema matches the database migration and prevents duplicate submissions
|
||||
unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
|
||||
]);
|
||||
|
||||
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||
|
||||
9
src/lib/server/db/test.ts
Normal file
9
src/lib/server/db/test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as schema from './schema';
|
||||
|
||||
if (!Bun.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
|
||||
|
||||
const testClient = new Database(Bun.env.TEST_DATABASE_URL);
|
||||
|
||||
export const testDb = drizzle(testClient, { schema });
|
||||
186
src/lib/stores/game-persistence.svelte.ts
Normal file
186
src/lib/stores/game-persistence.svelte.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
||||
|
||||
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
|
||||
// Used to attribute stats to a player who hasn't signed in.
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
const key = "bibdle-anonymous-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// Reactive store that keeps in-memory game state in sync with localStorage.
|
||||
// Accepts getter functions (rather than plain values) so Svelte's reactivity
|
||||
// system can track dependencies and re-run effects when they change.
|
||||
type AuthUser = {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export function createGamePersistence(
|
||||
getDate: () => string,
|
||||
getReference: () => string,
|
||||
getCorrectBookId: () => string,
|
||||
getUser: () => AuthUser | null | undefined,
|
||||
) {
|
||||
let guesses = $state<Guess[]>([]);
|
||||
let anonymousId = $state("");
|
||||
let statsSubmitted = $state(false);
|
||||
let chapterGuessCompleted = $state(false);
|
||||
let chapterCorrect = $state(false);
|
||||
|
||||
// On mount (and if the user logs in/out), resolve the player's identity and
|
||||
// restore per-day flags from localStorage.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const user = getUser();
|
||||
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||
if (user) {
|
||||
anonymousId = user.id;
|
||||
} else {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
}
|
||||
|
||||
// Tell analytics which player this is so events are grouped correctly.
|
||||
if ((window as any).umami) {
|
||||
(window as any).umami.identify(anonymousId);
|
||||
}
|
||||
if (user) {
|
||||
const nameParts = [user.firstName, user.lastName].filter(Boolean);
|
||||
(window as any).rybbit?.identify(user.id, {
|
||||
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
|
||||
...(user.email ? { email: user.email } : {}),
|
||||
});
|
||||
} else {
|
||||
(window as any).rybbit?.identify(anonymousId);
|
||||
}
|
||||
|
||||
const date = getDate();
|
||||
const reference = getReference();
|
||||
|
||||
// Restore whether today's completion was already submitted to the server.
|
||||
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
||||
|
||||
// Restore the chapter bonus guess result. The stored value includes the
|
||||
// chapter the player selected, so we can re-derive whether it was correct.
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||
if (chapterGuessCompleted) {
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// On mount (and if the date or correct answer changes), load today's guesses
|
||||
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
|
||||
// each stored book ID against the correct answer.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const date = getDate();
|
||||
const correctBookId = getCorrectBookId();
|
||||
const key = `bibdle-guesses-${date}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) {
|
||||
guesses = [];
|
||||
return;
|
||||
}
|
||||
|
||||
let savedIds: string[] = JSON.parse(saved);
|
||||
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
|
||||
guesses = savedIds
|
||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||
.filter((g): g is Guess => g !== null);
|
||||
});
|
||||
|
||||
// Persist guesses to localStorage whenever they change. Only the book IDs are
|
||||
// stored — the full Guess shape is re-derived on load (see effect above).
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const date = getDate();
|
||||
localStorage.setItem(
|
||||
`bibdle-guesses-${date}`,
|
||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
||||
);
|
||||
});
|
||||
|
||||
// Called after stats are successfully submitted to the server so that
|
||||
// returning to the page doesn't trigger a duplicate submission.
|
||||
function markStatsSubmitted() {
|
||||
if (!browser) return;
|
||||
statsSubmitted = true;
|
||||
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
||||
}
|
||||
|
||||
// Marks the win as tracked for analytics. Returns true the first time (new
|
||||
// win), false on subsequent calls so the analytics event fires exactly once.
|
||||
function markWinTracked() {
|
||||
if (!browser) return;
|
||||
const key = `bibdle-win-tracked-${getDate()}`;
|
||||
if (localStorage.getItem(key) === "true") return false;
|
||||
localStorage.setItem(key, "true");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if the win has already been tracked in a previous render/session.
|
||||
// Used to skip the animation delay when returning to an already-won game.
|
||||
function isWinAlreadyTracked(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
||||
}
|
||||
|
||||
// Overwrites local state with the server's authoritative guess record.
|
||||
// Called when a logged-in user opens the game on a new device so their
|
||||
// progress from another device is restored.
|
||||
function hydrateFromServer(guessIds: string[]) {
|
||||
if (!browser) return;
|
||||
const correctBookId = getCorrectBookId();
|
||||
const date = getDate();
|
||||
guesses = guessIds
|
||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||
.filter((g): g is Guess => g !== null);
|
||||
}
|
||||
|
||||
// Called by the WinScreen after the player submits their chapter bonus guess.
|
||||
// Reads the result written to localStorage by WinScreen and updates reactive state.
|
||||
function onChapterGuessCompleted() {
|
||||
if (!browser) return;
|
||||
chapterGuessCompleted = true;
|
||||
const reference = getReference();
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get guesses() { return guesses; },
|
||||
set guesses(v: Guess[]) { guesses = v; },
|
||||
get anonymousId() { return anonymousId; },
|
||||
get statsSubmitted() { return statsSubmitted; },
|
||||
get chapterGuessCompleted() { return chapterGuessCompleted; },
|
||||
get chapterCorrect() { return chapterCorrect; },
|
||||
markStatsSubmitted,
|
||||
markWinTracked,
|
||||
isWinAlreadyTracked,
|
||||
onChapterGuessCompleted,
|
||||
hydrateFromServer,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { bibleBooks, type BibleBook } from '$lib/types/bible';
|
||||
|
||||
export interface Guess {
|
||||
book: BibleBook;
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
firstLetterMatch: boolean;
|
||||
}
|
||||
|
||||
export function getBookById(id: string): BibleBook | undefined {
|
||||
return bibleBooks.find((b) => b.id === id);
|
||||
}
|
||||
@@ -10,7 +18,47 @@ export function isAdjacent(id1: string, id2: string): boolean {
|
||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||
}
|
||||
|
||||
export function getGrade(numGuesses: number, popularity: number): string {
|
||||
export function getFirstLetter(bookName: string): string {
|
||||
const match = bookName.match(/[a-zA-Z]/);
|
||||
return match ? match[0] : bookName[0];
|
||||
}
|
||||
|
||||
export function evaluateGuess(guessBookId: string, correctBookId: string): Guess | null {
|
||||
const book = getBookById(guessBookId);
|
||||
const correctBook = getBookById(correctBookId);
|
||||
if (!book || !correctBook) return null;
|
||||
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(guessBookId, correctBookId);
|
||||
|
||||
// Special case: if correct book is in the Epistles + starts with "1",
|
||||
// any guess starting with "1" counts as first letter match
|
||||
const correctIsEpistlesWithNumber =
|
||||
(correctBook.section === "Pauline Epistles" ||
|
||||
correctBook.section === "General Epistles") &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(book.section === "Pauline Epistles" ||
|
||||
book.section === "General Epistles") &&
|
||||
book.name[0] === "1";
|
||||
|
||||
const firstLetterMatch =
|
||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||
? true
|
||||
: getFirstLetter(book.name).toUpperCase() ===
|
||||
getFirstLetter(correctBook.name).toUpperCase();
|
||||
|
||||
return {
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
firstLetterMatch,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGrade(numGuesses: number): string {
|
||||
if (numGuesses === 1) return "S+";
|
||||
if (numGuesses === 2) return "A+";
|
||||
if (numGuesses === 3) return "A";
|
||||
@@ -31,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
|
||||
}
|
||||
|
||||
export function toOrdinal(n: number): string {
|
||||
if (n >= 11 && n <= 13) {
|
||||
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||
return `${n}th`;
|
||||
}
|
||||
const mod = n % 10;
|
||||
@@ -49,4 +97,4 @@ export function generateUUID(): string {
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
98
src/lib/utils/share.ts
Normal file
98
src/lib/utils/share.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Guess } from './game';
|
||||
|
||||
export function getVerseSnippet(verseText: string): string {
|
||||
const words = verseText.trim().split(/\s+/);
|
||||
const slice = words.slice(0, 25);
|
||||
const text = slice.join(' ');
|
||||
|
||||
// Returns character index immediately after the Nth word (1-indexed)
|
||||
function posAfterWord(n: number): number {
|
||||
let pos = 0;
|
||||
for (let w = 0; w < Math.min(n, slice.length); w++) {
|
||||
if (w > 0) pos++; // space between words
|
||||
pos += slice[w].length;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
const start = posAfterWord(9);
|
||||
const end = posAfterWord(25);
|
||||
|
||||
// Find first punctuation mark between words 10 and 25
|
||||
const range = text.substring(start, end);
|
||||
const match = range.match(/[,;:.!?—–-]/);
|
||||
|
||||
function withClosedQuotes(snippet: string): string {
|
||||
const opens = (snippet.match(/\u201C/g) ?? []).length;
|
||||
const closes = (snippet.match(/\u201D/g) ?? []).length;
|
||||
const closeQuote = opens > closes ? '\u201D' : '';
|
||||
return `\u201C${snippet}...${closeQuote}\u201D`;
|
||||
}
|
||||
|
||||
if (match && match.index !== undefined) {
|
||||
const cutPos = start + match.index;
|
||||
return withClosedQuotes(text.substring(0, cutPos).trimEnd());
|
||||
}
|
||||
|
||||
return withClosedQuotes(text);
|
||||
}
|
||||
|
||||
export function generateShareText(params: {
|
||||
guesses: Guess[];
|
||||
correctBookId: string;
|
||||
dailyVerseDate: string;
|
||||
chapterCorrect: boolean;
|
||||
isLoggedIn: boolean;
|
||||
streak?: number;
|
||||
origin: string;
|
||||
verseText: string;
|
||||
}): string {
|
||||
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
|
||||
|
||||
const emojis = guesses
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((guess) => {
|
||||
if (guess.book.id === correctBookId) return "✅";
|
||||
if (guess.adjacent) return "‼️";
|
||||
if (guess.sectionMatch) return "🟩";
|
||||
if (guess.testamentMatch) return "🟧";
|
||||
return "🟥";
|
||||
})
|
||||
.join("");
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formattedDate = dateFormatter.format(
|
||||
new Date(`${dailyVerseDate}T00:00:00`),
|
||||
);
|
||||
|
||||
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
||||
|
||||
const guessWord = guesses.length === 1 ? "guess" : "guesses";
|
||||
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
|
||||
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
|
||||
|
||||
const lines = [
|
||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||
`${guesses.length} ${guessWord},${streakPart}`,
|
||||
`${emojis}${chapterStar}`
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function shareResult(shareText: string): Promise<void> {
|
||||
if ("share" in navigator) {
|
||||
await (navigator as any).share({ text: shareText });
|
||||
} else {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyToClipboard(shareText: string): Promise<void> {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
}
|
||||
68
src/lib/utils/stats-client.ts
Normal file
68
src/lib/utils/stats-client.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface StatsData {
|
||||
solveRank: number;
|
||||
guessRank: number;
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
tiedCount: number;
|
||||
percentile: number;
|
||||
guesses?: string[]; // Present when fetching an existing completion (cross-device sync)
|
||||
}
|
||||
|
||||
export async function submitCompletion(params: {
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
guessCount: number;
|
||||
guesses: string[];
|
||||
}): Promise<StatsData | null> {
|
||||
try {
|
||||
const response = await fetch("/api/submit-completion", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.stats) {
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
// Already submitted from another device — fetch existing stats
|
||||
return fetchExistingStats({ anonymousId: params.anonymousId, date: params.date });
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error("Stats server error:", result.error);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Stats submission failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchExistingStats(params: {
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
}): Promise<StatsData | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/stats?anonymousId=${params.anonymousId}&date=${params.date}`,
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.stats) {
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error("Stats server error:", result.error);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Stats fetch failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,21 @@ export interface UserStats {
|
||||
guessCount: number;
|
||||
grade: string;
|
||||
}>;
|
||||
worstDay: {
|
||||
date: string;
|
||||
guessCount: number;
|
||||
} | null;
|
||||
bestBook: {
|
||||
bookId: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
} | null;
|
||||
mostSeenBook: {
|
||||
bookId: string;
|
||||
count: number;
|
||||
} | null;
|
||||
totalBooksSeenOT: number;
|
||||
totalBooksSeenNT: number;
|
||||
}
|
||||
|
||||
export function getGradeColor(grade: string): string {
|
||||
|
||||
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>
|
||||
<link rel="icon" href={favicon} />
|
||||
<!-- <script
|
||||
defer
|
||||
src="https://umami.snail.city/script.js"
|
||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
||||
data-domains="bibdle.com,www.bibdle.com"
|
||||
></script> -->
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
</svelte:head>
|
||||
{@render children()}
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, sql, asc } from 'drizzle-orm';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
async function getTodayVerse(): Promise<DailyVerse> {
|
||||
// Get the current date (server-side)
|
||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
|
||||
// If there's an existing verse for the current date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: crypto.randomUUID(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const dailyVerse = await getTodayVerse();
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
dailyVerse,
|
||||
correctBookId: dailyVerse.bookId,
|
||||
correctBook
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
|
||||
@@ -91,13 +58,20 @@ export const actions: Actions = {
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
|
||||
// Count ties: how many have the SAME guessCount (excluding self)
|
||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||
|
||||
// Average guesses
|
||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||
|
||||
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
||||
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
||||
|
||||
import type { PageProps } from "./$types";
|
||||
import { browser } from "$app/environment";
|
||||
import { enhance } from "$app/forms";
|
||||
|
||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||
@@ -11,42 +10,50 @@
|
||||
import Credits from "$lib/components/Credits.svelte";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||
import { getGrade } from "$lib/utils/game";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
|
||||
interface Guess {
|
||||
book: BibleBook;
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
firstLetterMatch: boolean;
|
||||
}
|
||||
import { evaluateGuess } from "$lib/utils/game";
|
||||
import {
|
||||
generateShareText,
|
||||
shareResult,
|
||||
copyToClipboard as clipboardCopy,
|
||||
} from "$lib/utils/share";
|
||||
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
|
||||
import {
|
||||
submitCompletion,
|
||||
fetchExistingStats,
|
||||
type StatsData,
|
||||
} from "$lib/utils/stats-client";
|
||||
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let correctBookId = $derived(data.correctBookId);
|
||||
|
||||
let guesses = $state<Guess[]>([]);
|
||||
let correctBook = $derived(data.correctBook);
|
||||
let user = $derived(data.user);
|
||||
let session = $derived(data.session);
|
||||
|
||||
let searchQuery = $state("");
|
||||
|
||||
let copied = $state(false);
|
||||
let isDev = $state(false);
|
||||
let chapterGuessCompleted = $state(false);
|
||||
let chapterCorrect = $state(false);
|
||||
let authModalOpen = $state(false);
|
||||
let showWinScreen = $state(false);
|
||||
let statsData = $state<StatsData | null>(null);
|
||||
let streak = $state(0);
|
||||
let streakPercentile = $state<number | null>(null);
|
||||
|
||||
let anonymousId = $state("");
|
||||
let statsSubmitted = $state(false);
|
||||
let statsData = $state<{
|
||||
solveRank: number;
|
||||
guessRank: number;
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
tiedCount: number;
|
||||
percentile: number;
|
||||
} | null>(null);
|
||||
const persistence = createGamePersistence(
|
||||
() => dailyVerse.date,
|
||||
() => dailyVerse.reference,
|
||||
() => correctBookId,
|
||||
() => user,
|
||||
);
|
||||
|
||||
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(
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
@@ -57,351 +64,184 @@
|
||||
}),
|
||||
);
|
||||
|
||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
||||
let grade = $derived(
|
||||
isWon
|
||||
? guesses.length === 1 && chapterCorrect
|
||||
? "S++"
|
||||
: getGrade(
|
||||
guesses.length,
|
||||
getBookById(correctBookId)?.popularity ?? 0,
|
||||
)
|
||||
: "",
|
||||
let isWon = $derived(
|
||||
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||
);
|
||||
let blurChapter = $derived(
|
||||
isWon && guesses.length === 1 && !chapterGuessCompleted,
|
||||
isWon &&
|
||||
persistence.guesses.length === 1 &&
|
||||
!persistence.chapterGuessCompleted,
|
||||
);
|
||||
|
||||
function getBookById(id: string): BibleBook | undefined {
|
||||
return bibleBooks.find((b) => b.id === id);
|
||||
}
|
||||
async function submitGuess(bookId: string) {
|
||||
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||
|
||||
function isAdjacent(id1: string, id2: string): boolean {
|
||||
const b1 = getBookById(id1);
|
||||
const b2 = getBookById(id2);
|
||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||
}
|
||||
const guess = evaluateGuess(bookId, correctBookId);
|
||||
if (!guess) return;
|
||||
|
||||
function submitGuess(bookId: string) {
|
||||
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) {
|
||||
if (persistence.guesses.length === 0) {
|
||||
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
||||
if (
|
||||
localStorage.getItem(key) !== "true" &&
|
||||
browser &&
|
||||
localStorage.getItem(key) !== "true" &&
|
||||
(window as any).umami
|
||||
) {
|
||||
(window as any).umami.track("First guess");
|
||||
(window as any).rybbit?.event("First guess");
|
||||
localStorage.setItem(key, "true");
|
||||
}
|
||||
}
|
||||
|
||||
guesses = [
|
||||
{
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
firstLetterMatch,
|
||||
},
|
||||
...guesses,
|
||||
];
|
||||
|
||||
persistence.guesses = [guess, ...persistence.guesses];
|
||||
searchQuery = "";
|
||||
}
|
||||
|
||||
function generateUUID(): string {
|
||||
// Try native randomUUID if available
|
||||
if (typeof window.crypto.randomUUID === "function") {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback UUID v4 generator for older browsers
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r =
|
||||
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
if (
|
||||
guess.book.id === correctBookId &&
|
||||
browser &&
|
||||
persistence.anonymousId
|
||||
) {
|
||||
statsData = await submitCompletion({
|
||||
anonymousId: persistence.anonymousId,
|
||||
date: dailyVerse.date,
|
||||
guessCount: persistence.guesses.length,
|
||||
guesses: persistence.guesses.map((g) => g.book.id),
|
||||
});
|
||||
if (statsData) {
|
||||
persistence.markStatsSubmitted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload when the user returns to a stale tab on a new calendar day
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const loadedDate = new Date().toLocaleDateString("en-CA");
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) return;
|
||||
const now = new Date().toLocaleDateString("en-CA");
|
||||
if (now !== loadedDate) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
onVisibilityChange,
|
||||
);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(
|
||||
`bibdle-guesses-${dailyVerse.date}`,
|
||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
||||
);
|
||||
isDev =
|
||||
window.location.host === "localhost:5173" ||
|
||||
window.location.host === "test.bibdle.com";
|
||||
});
|
||||
|
||||
// Auto-submit stats when user wins
|
||||
// Fetch stats on page load if user already won in a previous session (same device)
|
||||
$effect(() => {
|
||||
console.log("Stats effect triggered:", {
|
||||
browser,
|
||||
isWon,
|
||||
anonymousId,
|
||||
statsSubmitted,
|
||||
statsData,
|
||||
if (
|
||||
!browser ||
|
||||
!isWon ||
|
||||
!persistence.anonymousId ||
|
||||
statsData ||
|
||||
!persistence.statsSubmitted
|
||||
)
|
||||
return;
|
||||
fetchExistingStats({
|
||||
anonymousId: persistence.anonymousId,
|
||||
date: dailyVerse.date,
|
||||
}).then((data) => {
|
||||
statsData = data;
|
||||
});
|
||||
|
||||
if (!browser || !isWon || !anonymousId) {
|
||||
console.log("Basic conditions not met");
|
||||
return;
|
||||
}
|
||||
|
||||
if (statsSubmitted && !statsData) {
|
||||
console.log("Fetching existing stats...");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
|
||||
if (result.success && result.stats) {
|
||||
console.log("Setting stats data:", result.stats);
|
||||
statsData = result.stats;
|
||||
localStorage.setItem(
|
||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
||||
"true",
|
||||
);
|
||||
} else if (result.error) {
|
||||
console.error("Server error:", result.error);
|
||||
} else {
|
||||
console.error("Unexpected response format:", result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Stats fetch failed:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Submitting stats...");
|
||||
|
||||
async function submitStats() {
|
||||
try {
|
||||
const payload = {
|
||||
anonymousId,
|
||||
date: dailyVerse.date,
|
||||
guessCount: guesses.length,
|
||||
};
|
||||
|
||||
console.log("Sending POST request with:", payload);
|
||||
|
||||
const response = await fetch("/api/submit-completion", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
|
||||
if (result.success && result.stats) {
|
||||
console.log("Setting stats data:", result.stats);
|
||||
statsData = result.stats;
|
||||
statsSubmitted = true;
|
||||
localStorage.setItem(
|
||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
||||
"true",
|
||||
);
|
||||
} else if (result.error) {
|
||||
console.error("Server error:", result.error);
|
||||
} else {
|
||||
console.error("Unexpected response format:", result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Stats submission failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
submitStats();
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
if (!browser || !isWon) return;
|
||||
const key = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
if (localStorage.getItem(key) === "true") return;
|
||||
if ((window as any).umami) {
|
||||
const isNew = persistence.markWinTracked();
|
||||
if (isNew && (window as any).umami) {
|
||||
(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 {
|
||||
const emojis = guesses
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((guess) => {
|
||||
if (guess.book.id === correctBookId) return "✅";
|
||||
if (guess.adjacent) return "‼️";
|
||||
if (guess.sectionMatch) return "🟩";
|
||||
if (guess.testamentMatch) return "🟧";
|
||||
return "🟥";
|
||||
})
|
||||
.join("");
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formattedDate = dateFormatter.format(
|
||||
new Date(`${dailyVerse.date}T00:00:00`),
|
||||
);
|
||||
const siteUrl = window.location.origin;
|
||||
return [
|
||||
`📖 Bibdle | ${formattedDate} 📖`,
|
||||
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
||||
siteUrl,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function share() {
|
||||
if (!browser) return;
|
||||
|
||||
const shareText = generateShareText();
|
||||
|
||||
try {
|
||||
if ("share" in navigator) {
|
||||
await (navigator as any).share({ text: shareText });
|
||||
} else {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
// Fetch streak when the player wins
|
||||
$effect(() => {
|
||||
if (!browser || !isWon || !persistence.anonymousId) return;
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||
streak = result;
|
||||
if (result >= 2) {
|
||||
fetchStreakPercentile(result, localDate).then((p) => {
|
||||
streakPercentile = p;
|
||||
});
|
||||
}
|
||||
} 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 getShareText(): string {
|
||||
return generateShareText({
|
||||
guesses: persistence.guesses,
|
||||
correctBookId,
|
||||
dailyVerseDate: dailyVerse.date,
|
||||
chapterCorrect: persistence.chapterCorrect,
|
||||
isLoggedIn: !!user,
|
||||
streak,
|
||||
origin: window.location.origin,
|
||||
verseText: dailyVerse.verseText,
|
||||
});
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
@@ -410,7 +250,7 @@
|
||||
if (useClipboard) {
|
||||
copied = true;
|
||||
}
|
||||
share()
|
||||
shareResult(getShareText())
|
||||
.then(() => {
|
||||
if (useClipboard) {
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||
<!-- <meta
|
||||
name="description"
|
||||
content="Guess which book of the Bible a verse comes from."
|
||||
/> -->
|
||||
</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">
|
||||
<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 />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||
<span class="big-text"
|
||||
>{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 class="flex flex-col gap-6">
|
||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||
<div class="animate-fade-in-up animate-delay-200">
|
||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||
</div>
|
||||
|
||||
{#if !isWon}
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||
{:else}
|
||||
<WinScreen
|
||||
{grade}
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
{copyToClipboard}
|
||||
bind:copied
|
||||
{statsSubmitted}
|
||||
guessCount={guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={() => {
|
||||
chapterGuessCompleted = true;
|
||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
|
||||
</div>
|
||||
{:else if showWinScreen}
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<WinScreen
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
copyToClipboard={handleCopyToClipboard}
|
||||
bind:copied
|
||||
statsSubmitted={persistence.statsSubmitted}
|
||||
guessCount={persistence.guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||
shareText={getShareText()}
|
||||
verseText={dailyVerse.verseText}
|
||||
{streak}
|
||||
{streakPercentile}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GuessesTable {guesses} {correctBookId} />
|
||||
<div class="animate-fade-in-up animate-delay-600">
|
||||
<GuessesTable guesses={persistence.guesses} {correctBookId} />
|
||||
</div>
|
||||
|
||||
{#if isWon}
|
||||
<Credits />
|
||||
<div class="animate-fade-in-up animate-delay-800">
|
||||
<Credits />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#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}
|
||||
</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 { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { and, eq, asc } from 'drizzle-orm';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const { anonymousId, date, guessCount } = await request.json();
|
||||
const { anonymousId, date, guessCount, guesses } = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
anonymousId,
|
||||
date,
|
||||
guessCount,
|
||||
guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
|
||||
completedAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
@@ -44,11 +45,9 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
// 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;
|
||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
|
||||
// Count ties: how many have the SAME guessCount (excluding self)
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
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,10 +10,54 @@ html, body {
|
||||
background: oklch(89.126% 0.06134 298.626);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
background: oklch(18% 0.03 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
.big-text {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
color: rgb(107 114 128);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.big-text {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
}
|
||||
|
||||
/* Page load animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.8s ease-out both;
|
||||
}
|
||||
|
||||
.animate-delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.animate-delay-400 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.animate-delay-600 {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.animate-delay-800 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
@@ -1,24 +1,42 @@
|
||||
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 type { PageServerLoad } from './$types';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const anonymousId = url.searchParams.get('anonymousId');
|
||||
|
||||
if (!anonymousId) {
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
// Check if user is authenticated
|
||||
if (!locals.user) {
|
||||
return {
|
||||
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 {
|
||||
// Get all completions for this user
|
||||
const completions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
||||
.where(eq(dailyCompletions.anonymousId, userId))
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
if (completions.length === 0) {
|
||||
@@ -38,8 +56,15 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
},
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
recentCompletions: []
|
||||
}
|
||||
recentCompletions: [],
|
||||
worstDay: null,
|
||||
bestBook: null,
|
||||
mostSeenBook: null,
|
||||
totalBooksSeenOT: 0,
|
||||
totalBooksSeenNT: 0
|
||||
},
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,26 +89,29 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
const sortedDates = completions
|
||||
.map((c: DailyCompletion) => c.date)
|
||||
.sort();
|
||||
|
||||
|
||||
let currentStreak = 0;
|
||||
let bestStreak = 0;
|
||||
let tempStreak = 1;
|
||||
|
||||
|
||||
if (sortedDates.length > 0) {
|
||||
// Check if current streak is active (includes today or yesterday)
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
// Use the user's local date passed from the client
|
||||
const today = userToday;
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
|
||||
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
||||
currentStreak = 1;
|
||||
|
||||
|
||||
// Count backwards from the most recent date
|
||||
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||
const currentDate = new Date(sortedDates[i + 1]);
|
||||
const prevDate = new Date(sortedDates[i]);
|
||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
|
||||
if (daysDiff === 1) {
|
||||
currentStreak++;
|
||||
} else {
|
||||
@@ -91,14 +119,14 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Calculate best streak
|
||||
bestStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
|
||||
if (daysDiff === 1) {
|
||||
tempStreak++;
|
||||
} else {
|
||||
@@ -118,6 +146,66 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
grade: getGradeFromGuesses(c.guessCount)
|
||||
}));
|
||||
|
||||
// Calculate worst day (highest guess count)
|
||||
const worstDay = completions.reduce((max, c) =>
|
||||
c.guessCount > max.guessCount ? c : max,
|
||||
completions[0]
|
||||
);
|
||||
|
||||
// Get all daily verses to link completions to books
|
||||
const allVerses = await db
|
||||
.select()
|
||||
.from(dailyVerses);
|
||||
|
||||
// Create a map of date -> bookId
|
||||
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
|
||||
|
||||
// Calculate book-specific stats
|
||||
const bookStats = new Map<string, { count: number; totalGuesses: number }>();
|
||||
|
||||
for (const completion of completions) {
|
||||
const bookId = dateToBookId.get(completion.date);
|
||||
if (bookId) {
|
||||
const existing = bookStats.get(bookId) || { count: 0, totalGuesses: 0 };
|
||||
bookStats.set(bookId, {
|
||||
count: existing.count + 1,
|
||||
totalGuesses: existing.totalGuesses + completion.guessCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find book you know the best (lowest avg guesses)
|
||||
let bestBook: { bookId: string; avgGuesses: number; count: number } | null = null;
|
||||
for (const [bookId, stats] of bookStats.entries()) {
|
||||
const avgGuesses = stats.totalGuesses / stats.count;
|
||||
if (!bestBook || avgGuesses < bestBook.avgGuesses) {
|
||||
bestBook = { bookId, avgGuesses, count: stats.count };
|
||||
}
|
||||
}
|
||||
|
||||
// Find most seen book
|
||||
let mostSeenBook: { bookId: string; count: number } | null = null;
|
||||
for (const [bookId, stats] of bookStats.entries()) {
|
||||
if (!mostSeenBook || stats.count > mostSeenBook.count) {
|
||||
mostSeenBook = { bookId, count: stats.count };
|
||||
}
|
||||
}
|
||||
|
||||
// Count unique books by testament
|
||||
const oldTestamentBooks = new Set<string>();
|
||||
const newTestamentBooks = new Set<string>();
|
||||
|
||||
for (const [bookId, _] of bookStats.entries()) {
|
||||
const book = bibleBooks.find(b => b.id === bookId);
|
||||
if (book) {
|
||||
if (book.testament === 'old') {
|
||||
oldTestamentBooks.add(bookId);
|
||||
} else {
|
||||
newTestamentBooks.add(bookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalSolves,
|
||||
@@ -125,15 +213,34 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
gradeDistribution,
|
||||
currentStreak,
|
||||
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) {
|
||||
console.error('Error fetching user stats:', error);
|
||||
return {
|
||||
stats: null,
|
||||
error: 'Failed to fetch stats'
|
||||
error: 'Failed to fetch stats',
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -146,4 +253,4 @@ function getGradeFromGuesses(guessCount: number): string {
|
||||
if (guessCount >= 7 && guessCount <= 10) return "B";
|
||||
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
||||
return "C";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getGradeColor,
|
||||
formatDate,
|
||||
getStreakMessage,
|
||||
getPerformanceMessage,
|
||||
type UserStats
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import {
|
||||
formatDate,
|
||||
type UserStats,
|
||||
} from "$lib/utils/stats";
|
||||
|
||||
interface PageData {
|
||||
stats: UserStats | null;
|
||||
error?: string;
|
||||
user?: any;
|
||||
session?: any;
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let authModalOpen = $state(false);
|
||||
let anonymousId = $state("");
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
@@ -31,25 +35,12 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const 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;
|
||||
}
|
||||
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function getGradePercentage(count: number, total: number): number {
|
||||
return total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
function getBookName(bookId: string): string {
|
||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||
}
|
||||
|
||||
$inspect(data);
|
||||
@@ -57,36 +48,74 @@
|
||||
|
||||
<svelte:head>
|
||||
<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>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div
|
||||
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1>
|
||||
<p class="text-gray-600">Track your Bibdle performance over time</p>
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||
Your Stats
|
||||
</h1>
|
||||
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||
Track your Bibdle performance over time
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="mt-4 text-gray-600">Loading your stats...</p>
|
||||
<div
|
||||
class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="mt-4 text-gray-300">Loading your stats...</p>
|
||||
</div>
|
||||
{:else if data.requiresAuth}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<h2 class="text-2xl font-bold text-blue-200 mb-4">
|
||||
Authentication Required
|
||||
</h2>
|
||||
<p class="text-blue-300 mb-6">
|
||||
You must be logged in to see your stats.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={() => (authModalOpen = true)}
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
🔐 Sign In / Sign Up
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.error}
|
||||
<div class="text-center py-12">
|
||||
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
|
||||
<p class="text-red-700">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
<div
|
||||
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<p class="text-red-300">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Return to Game
|
||||
@@ -95,112 +124,209 @@
|
||||
</div>
|
||||
{:else if !data.stats}
|
||||
<div class="text-center py-12">
|
||||
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto">
|
||||
<p class="text-yellow-700">No stats available.</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
|
||||
<Container class="p-8 max-w-md mx-auto">
|
||||
<div class="text-yellow-400 mb-4 text-lg">
|
||||
No stats available yet.
|
||||
</div>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Start playing to build your stats!
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
||||
>
|
||||
Start Playing
|
||||
</a>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
{:else}
|
||||
{@const stats = data.stats}
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Total Solves -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
|
||||
<!-- Key Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
||||
<!-- Current Streak -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-amber-600 mb-2">{stats.totalSolves}</div>
|
||||
<div class="text-gray-600">Total Solves</div>
|
||||
{#if stats.totalSolves > 0}
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{getPerformanceMessage(stats.avgGuesses)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-2xl md:text-3xl mb-1">🔥</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||
>
|
||||
{stats.currentStreak}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Current Streak
|
||||
</div>
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600 mb-2">{stats.avgGuesses}</div>
|
||||
<div class="text-gray-600">Avg. Guesses</div>
|
||||
<div class="text-sm text-gray-500 mt-1">per solve</div>
|
||||
</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 class="text-2xl md:text-3xl mb-1">🎯</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-blue-400 mb-1"
|
||||
>
|
||||
{stats.avgGuesses}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Avg Guesses
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Grade Distribution -->
|
||||
{#if stats.totalSolves > 0}
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{#each Object.entries(stats.gradeDistribution) as [grade, count]}
|
||||
{@const percentage = getGradePercentage(count, stats.totalSolves)}
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}">
|
||||
{grade}
|
||||
</span>
|
||||
<!-- Book Stats Grid -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6"
|
||||
>
|
||||
<!-- Worst Day -->
|
||||
{#if stats.worstDay}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">😅</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Worst Day
|
||||
</div>
|
||||
<div
|
||||
class="text-xl md:text-2xl font-bold text-red-400 truncate"
|
||||
>
|
||||
{stats.worstDay.guessCount} guesses
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{formatDate(stats.worstDay.date)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-800">{count}</div>
|
||||
<div class="text-sm text-gray-500">{percentage}%</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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="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
|
||||
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>
|
||||
{/each}
|
||||
</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>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</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
|
||||
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;
|
||||
|
||||
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
|
||||
|
||||
## 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
|
||||
|
||||
- created rss feed
|
||||
- fixed "first letter" clue edge cases
|
||||
- fixed "first letter" clue edge cases / easter egg
|
||||
- updated ranking formula
|
||||
|
||||
## 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 imposter mode, v0.1 (mom likes it) but needs work
|
||||
|
||||
## january 8th
|
||||
|
||||
- posted on Hacker News and LinkedIn, got 960 visitors in one day
|
||||
|
||||
## january 5th
|
||||
|
||||
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
||||
|
||||
Reference in New Issue
Block a user