48 Commits

Author SHA1 Message Date
George Powell
bd36f29419 Updated streak-percentile to count all players from the last 30 days (or
all active streaks if players have a greater than 30 day streak)
2026-02-22 00:25:08 -05:00
George Powell
3036264d44 Add Rybbit analytics alongside Umami
- Load Rybbit script via app.html (recommended SvelteKit approach)
- Mirror all Umami custom events (First guess, Guessed correctly, Share, Copy to Clipboard, social link clicks) with rybbit.event()
- Identify logged-in users with name/email traits; anonymous users by stable UUID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:13:41 -05:00
George Powell
6554ef8f41 Added streak percentage 2026-02-21 16:17:06 -05:00
George Powell
c3307b3920 Added streak counter 2026-02-21 01:24:16 -05:00
George Powell
19646c72ca WIP new share menu 2026-02-21 00:38:09 -05:00
George Powell
e592751a1c removed unneccesary code i think 2026-02-19 17:59:23 -05:00
George Powell
77cc83841d correctly pass anonymousid to the auth modal in the root route 2026-02-18 18:32:45 -05:00
George Powell
e8b2d2e35e Possible fix for sign in with apple migrations failing 2026-02-18 17:54:01 -05:00
George Powell
c50cccd3d3 validateSessionToken() now returns more user data 2026-02-18 14:53:31 -05:00
George Powell
638a789a0f device always replaces local localStorage completion with the
authoritative DB record
2026-02-18 14:48:02 -05:00
George Powell
e815e15ce5 Countdown timer now shows when there is a new verse available 2026-02-18 14:03:07 -05:00
George Powell
e6081c28f1 Refactor game logic into utility modules and add cross-device sync
Extracted game state management, share logic, and stats API calls into dedicated modules (game-persistence.svelte.ts, share.ts, stats-client.ts), and moved daily verse loading to client-side to fix timezone issues. Added a guesses column to daily_completions for cross-device state restoration for logged-in users, a new GET /api/stats endpoint, and a staging deploy script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 13:25:40 -05:00
George Powell
2de4e9e2a7 Another attempt at fixing Cross-site POST form submissions are forbidden 2026-02-13 01:56:24 -05:00
George Powell
ea7a848125 Allow for apple bypass 2026-02-13 01:52:48 -05:00
George Powell
1719e0bbbf switched to Bun.env for apple-auth.ts 2026-02-13 01:47:29 -05:00
George Powell
885adad756 added test.bibdle.com domain 2026-02-13 01:35:31 -05:00
George Powell
1b96919acd added --bun flag to deploy.sh 2026-02-13 01:33:36 -05:00
George Powell
8ef2a41a69 Added Sign In with Apple test route 2026-02-13 01:06:21 -05:00
George Powell
ac6ec051d4 Added Sign In with Apple 2026-02-13 00:57:44 -05:00
George Powell
a12c7d011a added some nice animation details 2026-02-13 00:36:06 -05:00
George Powell
77ffd6fbee Implement client-side timezone handling for daily verses
Refactored the daily verse system to properly handle users across different
timezones. Previously, the server used a fixed timezone (America/New_York),
causing users in other timezones to see incorrect verses near midnight.

Key changes:

**Server-side refactoring:**
- Extract `getVerseForDate()` into `src/lib/server/daily-verse.ts` for reuse
- Page load now uses UTC date for initial SSR (fast initial render)
- New `/api/daily-verse` POST endpoint accepts client-calculated date
- Server no longer calculates dates; uses client-provided date directly

**Client-side timezone handling:**
- Client calculates local date using browser's timezone on mount
- If server date doesn't match local date, fetches correct verse via API
- Changed verse data from `$derived` to `$state` to fix reactivity issues
- Mutating props was causing updates to fail; now uses local state
- Added effect to reload page when user returns to stale tab on new day

**Stats page improvements:**
- Accept `tz` query parameter for accurate streak calculations
- Use client's local date when determining "today" for current streaks
- Prevents timezone-based streak miscalculations

**Developer experience:**
- Added debug panel showing client local time vs daily verse date
- Added console logging for timezone fetch process
- Comprehensive test suite for timezone handling and streak logic

**UI improvements:**
- Share text uses 📜 emoji for logged-in users, 📖 for anonymous
- Stats link now includes timezone parameter for accurate display

This ensures users worldwide see the correct daily verse for their local
date, and streaks are calculated based on their timezone, not server time.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 23:37:08 -05:00
George Powell
f6652e59a7 fixed weird signin bug 2026-02-12 20:24:38 -05:00
George Powell
290fb06fe9 Reordered guesses table and added emphasis 2026-02-11 23:42:50 -05:00
George Powell
df8a9e62bb Add staggered page load animations
Implement elegant fadeInUp animations with staggered delays for main page
elements to create a polished, progressive reveal effect on page load.

Changes:
- layout.css: Added fadeInUp keyframes and delay utility classes
  (200ms, 400ms, 600ms, 800ms)
- +page.svelte: Applied animations to title, date, verse display,
  search input, guesses table, and credits

Animation sequence:
1. Title (0ms)
2. Date + Verse Display (200ms)
3. Search Input (400ms)
4. Guesses Table (600ms)
5. Credits (800ms - when won)

Creates a smooth, professional page load experience without changing any
existing design or functionality.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 17:24:14 -05:00
George Powell
730b65201a Redesign stats page with dark theme and enhanced statistics
- Implement dark gradient background with glassmorphism cards
- Add new statistics: worst day, best book, most seen book, unique books by testament
- Design mobile-first responsive grid layout with optimized spacing
- Update Container component to support dark theme (bg-white/10, border-white/20)
- Calculate book-specific stats by linking completions to daily verses
- Improve visual hierarchy with icons and color-coded stat cards

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 13:01:53 -05:00
George Powell
78440cfbc3 Fix infinite stats submission and improve mobile button layout
- Fix infinite loop in stats submission effect by adding statsData to early return condition
- Make bottom buttons (View Stats, Sign In/Out) full-width on small screens
- Buttons now stack vertically on mobile, side-by-side on medium+ screens

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 21:05:10 -05:00
George Powell
482ee0a83a added twitter logo 2026-02-09 12:16:44 -05:00
George Powell
342bd323a1 Merge branch 'main' into auth
Brought in latest changes from main including:
- RSS feed implementation
- First letter edge case fixes
- Updated ranking formula

Resolved conflicts by:
- Combining .env.example variables from both branches
- Keeping auth version (3.0.0alpha)
- Preserving extended user schema from auth
- Keeping onMount umami approach and adding RSS feed link

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 12:03:31 -05:00
George Powell
95725ab4fe Add test infrastructure and signin migration tests
- Add test-specific Drizzle config and database connection
- Create test version of auth module using test database
- Add comprehensive integration tests for signin migration logic
- Add unit tests for deduplication algorithm
- Tests cover edge cases like multiple duplicates, timing, and error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 18:49:21 -05:00
George Powell
06ff0820ce Implement anonymous stats migration on signin
- Fix AuthModal to pass anonymousId on both signin and signup
- Add comprehensive migration logic in signin that moves anonymous completion stats to authenticated user
- Implement deduplication algorithm to handle overlapping completion dates
- Maintain earliest completion when duplicates exist

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 18:49:14 -05:00
George Powell
3cf95152e6 Replace unique constraint with index on dailyCompletions
Changes unique constraint on (anonymousId, date) to a regular index for better performance and to support the migration logic where duplicates may temporarily exist before deduplication.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 18:49:07 -05:00
George Powell
c04899d419 removed dumb $env/dynamic/private import and replaced with Bun.env 2026-02-05 18:14:13 -05:00
George Powell
6161ef75a1 added bun types as a dev dependency 2026-02-05 18:13:47 -05:00
George Powell
9d7399769a some little wording changes xd 2026-02-05 18:13:30 -05:00
George Powell
b1591229ba Move UI controls to bottom and require authentication for stats
- Moved stats button, auth buttons, and debug info to bottom of main page
- Added authentication requirement for /stats route
- Show login prompt for unauthenticated users accessing stats
- Include AuthModal for sign in/sign up from stats page

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 17:57:29 -05:00
George Powell
96024d5048 Support authenticated users in stats and page loading 2026-02-05 17:46:53 -05:00
George Powell
86f81cf9dd Use user ID for umami identify when authenticated 2026-02-05 17:46:14 -05:00
George Powell
24a5fdbb80 added umami events on social buttons 2026-02-05 17:43:51 -05:00
George Powell
dfe1c40a8a switched to bun:sqlite 2026-02-05 00:47:55 -05:00
George Powell
dfe784b744 added login modal 2026-02-05 00:47:47 -05:00
George Powell
6bced13543 Merge pull request #2 from pupperpowell/stats
added demo stats page (needs refinement)
2026-02-04 23:36:10 -05:00
George Powell
9406498cc9 Merge pull request #1 from pupperpowell/rss
created rss feeds
2026-02-02 02:53:50 -05:00
George Powell
3947e8adb0 rss improvements 2026-02-02 02:52:53 -05:00
George Powell
244113671e created rss feed 2026-02-02 02:07:12 -05:00
George Powell
5b9b2f76f4 added some more words to the "first letter" edge case 2026-02-02 01:32:17 -05:00
George Powell
f7ec0742e1 fixed "first letter" clue edge cases 2026-02-02 01:27:12 -05:00
George Powell
d797b980ea updated ranking formula 2026-02-02 00:48:35 -05:00
George Powell
ff228fb547 Update package.json version 2026-01-28 23:35:33 -05:00
72 changed files with 4926 additions and 1072 deletions

View File

@@ -5,7 +5,6 @@
"Read(./secrets/**)", "Read(./secrets/**)",
"Read(./config/credentials.json)", "Read(./config/credentials.json)",
"Read(./build)", "Read(./build)",
"Read(./**.xml)",
"Read(./embeddings**)" "Read(./embeddings**)"
] ]
} }

View File

@@ -1,5 +1,19 @@
DATABASE_URL=example.db DATABASE_URL=example.db
PUBLIC_SITE_URL=https://bibdle.com
# nodemailer
SMTP_USERNAME=email@example.com
SMTP_TOKEN=TOKEN
SMTP_SERVER=smtp.example.com
SMTP_PORT=port
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
# sign in with Discord
# sign in with google
# sign in with apple
AUTH_SECRET=your-random-secret-here AUTH_SECRET=your-random-secret-here
APPLE_ID=com.yourcompany.yourapp.client APPLE_ID=com.yourcompany.yourapp.client
APPLE_TEAM_ID=your-team-id APPLE_TEAM_ID=your-team-id

3
.gitignore vendored
View File

@@ -28,4 +28,5 @@ vite.config.ts.timestamp-*
llms-* llms-*
embeddings* embeddings*
*.xml *bible.xml
engwebu_usfx.xml

View File

@@ -59,8 +59,8 @@ bun run preview
# Database operations # Database operations
bun run db:push # Push schema changes to database bun run db:push # Push schema changes to database
bun run db:generate # Generate migrations bun run db:generate # Generate migrations (DO NOT RUN)
bun run db:migrate # Run migrations bun run db:migrate # Run migrations (DO NOT RUN)
bun run db:studio # Open Drizzle Studio GUI bun run db:studio # Open Drizzle Studio GUI
``` ```

View File

@@ -24143,7 +24143,7 @@
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse> <verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, Where is their God?</verse> <verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, Where is their God?</verse>
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse> <verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse> <verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse> <verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse> <verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse> <verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
@@ -33616,4 +33616,4 @@
</chapter> </chapter>
</book> </book>
</testament> </testament>
</bible> </bible>

View File

@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
```sh ```sh
# create a new project in the current directory # create a new project in the current directory
npx sv create bunx sv create
# create a new project in my-app # create a new project in my-app
npx sv create my-app bunx sv create my-app
``` ```
## Developing ## Developing
@@ -19,10 +19,10 @@ npx sv create my-app
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh ```sh
npm run dev bun run dev
# or start the server and open the app in a new browser tab # or start the server and open the app in a new browser tab
npm run dev -- --open bun run dev -- --open
``` ```
## Building ## Building
@@ -30,9 +30,9 @@ npm run dev -- --open
To create a production version of your app: To create a production version of your app:
```sh ```sh
npm run build bun run build
``` ```
You can preview the production build with `npm run preview`. You can preview the production build with `bun run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -6,7 +6,6 @@
"name": "bibdle", "name": "bibdle",
"dependencies": { "dependencies": {
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.6.2",
"fast-xml-parser": "^5.3.3", "fast-xml-parser": "^5.3.3",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
}, },
@@ -18,11 +17,11 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.8",
"@types/node": "^22.19.7", "@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"svelte": "^5.48.3", "svelte": "^5.48.5",
"svelte-check": "^4.3.5", "svelte-check": "^4.3.5",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -229,6 +228,8 @@
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -273,6 +274,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],

22
deploy-staging.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "Pulling latest changes..."
git pull
echo "Installing dependencies..."
bun i
echo "Pushing database changes..."
bun run db:generate
bun run db:migrate
echo "Building..."
bun --bun run build
echo "Restarting service..."
sudo systemctl restart bibdle-test
echo "Done!"

View File

@@ -10,7 +10,7 @@ echo "Installing dependencies..."
bun i bun i
echo "Building..." echo "Building..."
bun run build bun --bun run build
echo "Restarting service..." echo "Restarting service..."
sudo systemctl restart bibdle sudo systemctl restart bibdle

11
drizzle.test.config.ts Normal file
View 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
});

View 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`);

View 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": {}
}
}

View File

@@ -8,6 +8,20 @@
"when": 1765934144883, "when": 1765934144883,
"tag": "0000_clumsy_impossible_man", "tag": "0000_clumsy_impossible_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1770266674489,
"tag": "0001_loose_kree",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1770961427714,
"tag": "0002_outstanding_hiroim",
"breakpoints": true
} }
] ]
} }

View File

@@ -4,12 +4,14 @@
"version": "3.0.0alpha", "version": "3.0.0alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "bun --bun vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "bun test",
"test:watch": "bun test --watch",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
@@ -23,7 +25,7 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.8",
"@types/node": "^22.19.7", "@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
@@ -35,7 +37,6 @@
}, },
"dependencies": { "dependencies": {
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.6.2",
"fast-xml-parser": "^5.3.3", "fast-xml-parser": "^5.3.3",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
} }

31
scripts/analyze_top_users.sh Executable file
View 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
View 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

View 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';"

View 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();

View 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();

View 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 )) # 16 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"

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View 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

View 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}

View File

@@ -90,7 +90,7 @@
return chapterCounts[bookId] || 1; return chapterCounts[bookId] || 1;
} }
// Generate 6 random chapter options including the correct one // Generate 4 random chapter options including the correct one
function generateChapterOptions( function generateChapterOptions(
correctChapter: number, correctChapter: number,
totalChapters: number, totalChapters: number,
@@ -98,14 +98,14 @@
const options = new Set<number>(); const options = new Set<number>();
options.add(correctChapter); options.add(correctChapter);
if (totalChapters >= 6) { if (totalChapters >= 4) {
while (options.size < 6) { while (options.size < 4) {
const randomChapter = const randomChapter =
Math.floor(Math.random() * totalChapters) + 1; Math.floor(Math.random() * totalChapters) + 1;
options.add(randomChapter); options.add(randomChapter);
} }
} else { } else {
while (options.size < 6) { while (options.size < 4) {
const randomChapter = Math.floor(Math.random() * 10) + 1; const randomChapter = Math.floor(Math.random() * 10) + 1;
options.add(randomChapter); options.add(randomChapter);
} }
@@ -167,18 +167,18 @@
</script> </script>
<Container <Container
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md" class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
> >
<div class="text-center"> <div class="text-center">
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p> <p class="font-bold mb-3 text-lg sm:text-xl">
<p class="text-sm sm:text-base opacity-80 mb-6"> Bonus Challenge
Guess the chapter for an even higher grade <span class="text-base sm:text-lg opacity-60 font-normal"
>— guess the chapter for an even higher grade</span
>
</p> </p>
<div <div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6" {#each chapterOptions as chapter (chapter)}
>
{#each chapterOptions as chapter}
<button <button
onclick={() => handleChapterSelect(chapter)} onclick={() => handleChapterSelect(chapter)}
disabled={hasAnswered} disabled={hasAnswered}

View File

@@ -10,7 +10,7 @@
</script> </script>
<div <div
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}" class="inline-flex flex-col items-center bg-white/10 backdrop-blur-sm rounded-2xl border border-white/20 shadow-sm {className}"
> >
{@render children()} {@render children()}
</div> </div>

View File

@@ -1,88 +1,82 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
let timeUntilNext = $state(""); let timeUntilNext = $state("");
let intervalId: number | null = null; let newVerseReady = $state(false);
let intervalId: number | null = null;
let targetTime = 0;
function calculateTimeUntilFivePM(): string { function initTarget() {
const now = new Date(); const target = new Date();
const target = new Date(now); 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 function updateTimer() {
target.setHours(17, 0, 0, 0); const diff = targetTime - Date.now();
// If it's already past 5:00 PM, set target to tomorrow 5:00 PM if (diff <= 0) {
if (now.getTime() >= target.getTime()) { newVerseReady = true;
target.setDate(target.getDate() + 1); 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) { timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
return "00:00:00"; .toString()
} .padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
}
const hours = Math.floor(diff / (1000 * 60 * 60)); onMount(() => {
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); initTarget();
const seconds = Math.floor((diff % (1000 * 60)) / 1000); updateTimer();
intervalId = window.setInterval(updateTimer, 1000);
});
return `${hours.toString().padStart(2, "0")}h ${minutes onDestroy(() => {
.toString() if (intervalId) {
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`; clearInterval(intervalId);
} }
});
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);
}
});
</script> </script>
<div class="w-full"> <div class="w-full">
<div <div
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full" class="flex flex-col items-center 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"> {#if newVerseReady}
Next Verse In <p
</p> class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums"> >
{timeUntilNext} Next Verse In
</p> </p>
</div> <p class="text-4xl font-triodion font-black text-gray-800">Now</p>
<p
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mt-2"
>
(refresh page to see the new verse)
</p>
{:else}
<p
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
>
Next Verse In
</p>
<p
class="text-4xl font-triodion font-black text-gray-800 tabular-nums"
>
{timeUntilNext}
</p>
{/if}
</div>
</div> </div>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg"; import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
</script> </script>
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}> <div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
@@ -32,16 +33,34 @@
rel="noopener noreferrer" rel="noopener noreferrer"
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Bluesky" aria-label="Follow on Bluesky"
data-umami-event="Bluesky clicked"
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
> >
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" /> <img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
</a> </a>
<div class="w-0.5 h-8 bg-gray-400"></div> <div class="w-0.5 h-8 bg-gray-400"></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"></div>
<a <a
href="mailto:george+bibdle@silentsummit.co" href="mailto:george+bibdle@silentsummit.co"
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email" aria-label="Send email"
data-umami-event="Email clicked"
onclick={() => (window as any).rybbit?.event("Email clicked")}
> >
<svg <svg
class="w-8 h-8 text-gray-700" class="w-8 h-8 text-gray-700"

View File

@@ -2,6 +2,30 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import Button from "$lib/components/Button.svelte"; import Button from "$lib/components/Button.svelte";
let { anonymousId }: { anonymousId: string | null } = $props();
let seeding = $state(false);
async function seedHistory() {
if (!browser || !anonymousId || seeding) return;
seeding = true;
try {
const response = await fetch("/api/dev/seed-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ anonymousId })
});
const result = await response.json();
alert(
`Seeded! Inserted: ${result.inserted?.join(", ")}. Skipped (already exist): ${result.skipped?.join(", ") || "none"}`
);
} catch {
alert("Failed to seed history");
} finally {
seeding = false;
}
}
function clearLocalStorage() { function clearLocalStorage() {
if (!browser) return; if (!browser) return;
// Clear all bibdle-related localStorage items // Clear all bibdle-related localStorage items
@@ -86,4 +110,13 @@
> >
Clear LocalStorage Clear LocalStorage
</Button> </Button>
<Button
variant="secondary"
onclick={seedHistory}
disabled={seeding}
class="w-full py-4 md:py-2"
>
{seeding ? "Seeding..." : "Seed 10 Days of History"}
</Button>
</div> </div>

View File

@@ -1,20 +1,8 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks } from "$lib/types/bible"; import { bibleBooks } from "$lib/types/bible";
import { getFirstLetter, type Guess } from "$lib/utils/game";
import Container from "./Container.svelte"; import Container from "./Container.svelte";
interface Guess {
book: {
id: string;
name: string;
testament: string;
section: string;
};
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
firstLetterMatch: boolean;
}
let { let {
guesses, guesses,
correctBookId, correctBookId,
@@ -44,16 +32,27 @@
(correctBook?.section === "Pauline Epistles" || (correctBook?.section === "Pauline Epistles" ||
correctBook?.section === "General Epistles") && correctBook?.section === "General Epistles") &&
correctBook.name[0] === "1"; correctBook.name[0] === "1";
const guessStartsWithNumber = guess.book.name[0] === "1"; const guessIsEpistlesWithNumber =
(guess.book.section === "Pauline Epistles" ||
guess.book.section === "General Epistles") &&
guess.book.name[0] === "1";
if ( if (
correctIsEpistlesWithNumber && correctIsEpistlesWithNumber &&
guessStartsWithNumber && guessIsEpistlesWithNumber &&
guess.firstLetterMatch guess.firstLetterMatch
) { ) {
return "Yes"; // Special wordplay case const words = [
"Exactly",
"Right",
"Yes",
"Naturally",
"Of course",
"Sure",
];
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
} }
return guess.book.name[0]; // Normal case: just show the first letter return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
case "testament": case "testament":
return ( return (
guess.book.testament.charAt(0).toUpperCase() + guess.book.testament.charAt(0).toUpperCase() +
@@ -72,8 +71,7 @@
</h2> </h2>
<p class="text-gray-700 leading-relaxed italic"> <p class="text-gray-700 leading-relaxed italic">
Guess what book of the bible you think the verse is from. You will Guess what book of the bible you think the verse is from. You will
get clues to tell you if your guess is close or not. Green means the get clues to help you after each guess.
category is correct; red means wrong.
</p> </p>
</Container> </Container>
{:else} {:else}
@@ -83,47 +81,35 @@
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"
> >
<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"
>
Book
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
> >
Testament Testament
</div> </div>
<div <div
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700" class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
> >
Section Section
</div> </div>
<div <div
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700" class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
> >
First Letter First Letter
</div> </div>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
>
Book
</div>
</div> </div>
{#each guesses as guess, rowIndex (guess.book.id)} {#each guesses as guess, rowIndex (guess.book.id)}
<div class="flex gap-2 justify-center"> <div class="flex gap-2 justify-center">
<!-- Book Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
guess.book.id === correctBookId,
)}"
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span
>
</div>
<!-- Testament Column --> <!-- Testament Column -->
<div <div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor( class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.testamentMatch, guess.testamentMatch,
)}" )}"
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms" style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
> >
<span class="text-center leading-tight px-1 text-shadow-sm" <span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "testament")}</span >{getBoxContent(guess, "testament")}</span
@@ -136,7 +122,7 @@
guess.sectionMatch, guess.sectionMatch,
guess.adjacent, guess.adjacent,
)}" )}"
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms" style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
> >
<span class="text-center leading-tight px-1 text-shadow-sm" <span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "section")} >{getBoxContent(guess, "section")}
@@ -151,12 +137,24 @@
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor( class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.firstLetterMatch, guess.firstLetterMatch,
)}" )}"
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms" style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
> >
<span class="text-center leading-tight px-1 text-shadow-sm" <span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "firstLetter")}</span >{getBoxContent(guess, "firstLetter")}</span
> >
</div> </div>
<!-- Book Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
guess.book.id === correctBookId,
)}"
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span
>
</div>
</div> </div>
{/each} {/each}
</div> </div>

View File

@@ -1,241 +1,246 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
interface ImposterData { interface ImposterData {
verses: string[]; verses: string[];
refs: string[]; refs: string[];
imposterIndex: number; imposterIndex: number;
} }
let data: ImposterData | null = null; let data: ImposterData | null = $state(null);
let clicked: boolean[] = []; let clicked: boolean[] = $state([]);
let gameOver = false; let gameOver = $state(false);
let loading = true; let loading = $state(true);
let error: string | null = null; let error: string | null = $state(null);
async function loadGame() { async function loadGame() {
try { try {
const res = await fetch("/api/imposter"); const res = await fetch("/api/imposter");
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`); throw new Error(`HTTP ${res.status}: ${res.statusText}`);
} }
data = (await res.json()) as ImposterData; data = (await res.json()) as ImposterData;
clicked = new Array(data.verses.length).fill(false); clicked = new Array(data.verses.length).fill(false);
gameOver = false; gameOver = false;
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : "Unknown error"; error = e instanceof Error ? e.message : "Unknown error";
} finally { } finally {
loading = false; loading = false;
} }
} }
function handleClick(index: number) { function handleClick(index: number) {
if (gameOver || !data || clicked[index]) return; if (gameOver || !data || clicked[index]) return;
clicked[index] = true; clicked[index] = true;
if (index !== data.imposterIndex) { if (index !== data.imposterIndex) {
clicked[data.imposterIndex] = true; clicked[data.imposterIndex] = true;
} }
gameOver = true; gameOver = true;
} }
function newGame() { function newGame() {
loading = true; loading = true;
error = null; error = null;
data = null; data = null;
loadGame(); loadGame();
} }
onMount(loadGame); onMount(loadGame);
function formatVerse(verse: string): string { function formatVerse(verse: string): string {
let formatted = verse; let formatted = verse;
// Handle unbalanced opening/closing punctuation // Handle unbalanced opening/closing punctuation
const pairs: [string, string][] = [ const pairs: [string, string][] = [
["(", ")"], ["(", ")"],
["[", "]"], ["[", "]"],
["{", "}"], ["{", "}"],
['"', '"'], ['"', '"'],
["'", "'"], ["'", "'"],
["\u201C", "\u201D"], // \u201C ["\u201C", "\u201D"], // \u201C
["\u2018", "\u2019"], // \u2018 ["\u2018", "\u2019"], // \u2018
]; ];
for (const [open, close] of pairs) { for (const [open, close] of pairs) {
if (formatted.startsWith(open) && !formatted.includes(close)) { if (formatted.startsWith(open) && !formatted.includes(close)) {
formatted += "..." + close; formatted += "..." + close;
break; break;
} }
} }
for (const [open, close] of pairs) { for (const [open, close] of pairs) {
if (formatted.endsWith(close) && !formatted.includes(open)) { if (formatted.endsWith(close) && !formatted.includes(open)) {
formatted = open + "..." + formatted; formatted = open + "..." + formatted;
break; break;
} }
} }
if (/^[a-z]/.test(formatted)) { if (/^[a-z]/.test(formatted)) {
formatted = "..." + formatted; formatted = "..." + formatted;
} }
formatted = formatted.replace(/[,:;-—]$/, "..."); // Replace trailing punctuation with ellipsis
return formatted; // Preserve closing quotes/brackets that may have been added
} formatted = formatted.replace(
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
"...$1",
);
return formatted;
}
</script> </script>
<div class="imposter-game"> <div class="imposter-game">
{#if loading} {#if loading}
<p class="loading">Loading verses...</p> <p class="loading">Loading verses...</p>
{:else if error} {:else if error}
<div class="error"> <div class="error">
<p>Error: {error}</p> <p>Error: {error}</p>
<button on:click={newGame}>Retry</button> <button onclick={newGame}>Retry</button>
</div> </div>
{:else if data} {:else if data}
<!-- <div class="instructions"> <!-- <div class="instructions">
<p>Click the verse that doesn't belong (from a different book).</p> <p>Click the verse that doesn't belong (from a different book).</p>
</div> --> </div> -->
<div class="verses"> <div class="verses">
{#each data.verses as verse, i} {#each data.verses as verse, i}
<div class="verse-item"> <div class="verse-item">
<button <button
class="verse-button" class="verse-button"
class:clicked={clicked[i]} class:clicked={clicked[i]}
class:correct={clicked[i] && i === data.imposterIndex} class:correct={clicked[i] && i === data.imposterIndex}
class:wrong={clicked[i] && i !== data.imposterIndex} class:wrong={clicked[i] && i !== data.imposterIndex}
on:click={() => handleClick(i)} onclick={() => handleClick(i)}
disabled={gameOver} disabled={gameOver}
> >
{formatVerse(verse)} {formatVerse(verse)}
</button> </button>
{#if gameOver} {#if gameOver}
<div class="ref">{data.refs[i]}</div> <div class="ref">{data.refs[i]}</div>
{/if} {/if}
</div> </div>
{/each} {/each}
</div> </div>
{#if gameOver} {#if gameOver}
<div class="result"> <div class="result">
<button on:click={newGame}>New Game</button> <button onclick={newGame}>New Game</button>
</div> </div>
{/if} {/if}
{/if} {/if}
</div> </div>
<style> <style>
.imposter-game { .imposter-game {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
padding: 2rem; padding: 2rem;
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
} }
.loading, .loading,
.error { .error {
text-align: center; text-align: center;
} }
.instructions { /*.instructions {
text-align: center; text-align: center;
font-style: italic; font-style: italic;
color: #666; color: #666;
} }*/
.verses { .verses {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem; gap: 1.5rem;
width: 100%; width: 100%;
} }
.verse-item { .verse-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.verse-button { .verse-button {
padding: 1.5rem; padding: 1.5rem;
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.4; line-height: 1.4;
border: 3px solid #ddd; border: 3px solid #ddd;
background: #fafafa; background: #fafafa;
cursor: pointer; cursor: pointer;
border-radius: 12px; border-radius: 12px;
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 100px; min-height: 100px;
text-align: left; text-align: left;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
.verse-button:hover:not(.clicked):not(:disabled) { .verse-button:hover:not(.clicked):not(:disabled) {
border-color: #007bff; border-color: #007bff;
background: #f8f9ff; background: #f8f9ff;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15); box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
} }
.verse-button:disabled { .verse-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
} }
.verse-button.clicked { .verse-button.clicked {
cursor: default; cursor: default;
} }
.correct { .correct {
background: #d4edda !important; background: #d4edda !important;
border-color: #28a745 !important; border-color: #28a745 !important;
color: #155724; color: #155724;
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3); box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
} }
.wrong { .wrong {
background: #f8d7da !important; background: #f8d7da !important;
border-color: #dc3545 !important; border-color: #dc3545 !important;
color: #721c24; color: #721c24;
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3); box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
} }
.ref { .ref {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
color: #555; color: #555;
padding-top: 0.25rem; padding-top: 0.25rem;
} }
.verse-button.correct ~ .ref { .verse-button.correct ~ .ref {
color: #28a745; color: #28a745;
font-weight: bold; font-weight: bold;
} }
.verse-button.wrong ~ .ref { .verse-button.wrong ~ .ref {
color: #dc3545; color: #dc3545;
} }
.result { .result {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.result button, .result button,
.error button { .error button {
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
background: #007bff; background: #007bff;
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.result button:hover, .result button:hover,
.error button:hover { .error button:hover {
background: #0056b3; background: #0056b3;
} }
</style> </style>

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment";
import { fade } from "svelte/transition";
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
import Container from "./Container.svelte"; import Container from "./Container.svelte";
@@ -20,19 +22,52 @@
.replace(/^([a-z])/, (c) => c.toUpperCase()) .replace(/^([a-z])/, (c) => c.toUpperCase())
.replace(/[,:;-—]$/, "...") .replace(/[,:;-—]$/, "...")
); );
let showReference = $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);
}
});
</script> </script>
<Container class="w-full p-8 sm:p-12 bg-white/70"> <Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
<blockquote <blockquote
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center" class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
> >
{displayVerseText} {displayVerseText}
</blockquote> </blockquote>
{#if isWon} <div
<p class="transition-all duration-500 ease-in-out overflow-hidden"
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2" style="max-height: {showReference ? '200px' : '0px'};"
> >
{displayReference} {#if showReference}
</p> <p
{/if} transition:fade={{ duration: 400 }}
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}
</div>
</Container> </Container>

View File

@@ -1,10 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import { import { getBookById, toOrdinal } from "$lib/utils/game";
getBookById,
toOrdinal,
getNextGradeMessage,
} from "$lib/utils/game";
import { onMount } from "svelte"; import { onMount } from "svelte";
import Container from "./Container.svelte"; import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte"; import CountdownTimer from "./CountdownTimer.svelte";
@@ -25,7 +21,6 @@
} }
let { let {
grade,
statsData, statsData,
correctBookId, correctBookId,
handleShare, handleShare,
@@ -35,6 +30,22 @@
guessCount, guessCount,
reference, reference,
onChapterGuessCompleted, onChapterGuessCompleted,
shareText,
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;
streak?: number;
streakPercentile?: number | null;
} = $props(); } = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? ""); let bookName = $derived(getBookById(correctBookId)?.name ?? "");
@@ -57,9 +68,9 @@
if (guessCount === 1) { if (guessCount === 1) {
const n = Math.random(); const n = Math.random();
if (n < 0.99) { if (n < 0.99) {
return "🌟 First try! 🌟"; return "First try!";
} else { } else {
return "🗣️ Axios! 🗣️"; return "Axios!";
} }
} }
@@ -86,65 +97,31 @@
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<Container <Container
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in" class="w-full px-2 sm:px-4 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
> >
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed"> <p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
{congratulationsMessage} The verse is from {congratulationsMessage} The verse is from<br />
<span class="font-black text-3xl md:text-4xl">{bookName}</span>. <span class="font-black text-3xl md:text-4xl">{bookName}</span>.
</p> </p>
<p class="text-lg sm:text-xl md:text-2xl mt-4"> <p class="text-lg sm:text-xl md:text-2xl mt-2">
You guessed correctly after {guessCount} You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}. {guessCount === 1 ? "guess" : "guesses"}.
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
>{grade}</span
>
</p> </p>
<div class="flex justify-center mt-6"> {#if streak > 1}
{#if hasWebShare} <p class="big-text text-orange-500! text-lg! my-4">
<!-- mobile and arc in production --> 🔥 {streak} days in a row!
<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)}
</p> </p>
{#if streak >= 7}
<p class="font-black text-lg font-triodion">
Thank you for making Bibdle part of your daily routine!
</p>
{/if}
{#if streakPercentile !== null}
<p class="text-sm mt-4 text-gray-700 font-triodion">
{streakPercentile <= 50 ? "Only " : ""}{streakPercentile}% of players have a streak of {streak} or greater.
</p>
{/if}
{/if} {/if}
</Container> </Container>
@@ -225,6 +202,39 @@
<div class="text-sm opacity-80">Submitting stats...</div> <div class="text-sm opacity-80">Submitting stats...</div>
</Container> </Container>
{/if} {/if}
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
<div class="big-text font-black! text-center">Share your result</div>
<div class="share-buttons">
{#if hasWebShare}
<button
onclick={() => { (window as any).rybbit?.event("Share"); handleShare(); }}
data-umami-event="Share"
class="share-btn primary"
>
📤 Click to share
</button>
{:else}
<button
onclick={() => {
(window as any).rybbit?.event("Copy to Clipboard");
copyToClipboard();
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}}
data-umami-event="Copy to Clipboard"
class={`share-btn primary ${copySuccess ? "success" : ""}`}
>
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
</button>
{/if}
</div>
<div class="chat-window">
<p class="bubble">{shareText}</p>
</div>
</div>
</div> </div>
<style> <style>
@@ -242,4 +252,141 @@
.fade-in { .fade-in {
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }
/* ── Share card ── */
.share-card {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(8px);
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;
}
.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;
}
.share-card-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8e8e93;
margin: 0;
}
/* ── Chat window ── */
.chat-window {
--sent-color: #0b93f6;
--bg: oklch(93.996% 0.03041 300.209);
display: flex;
justify-content: center;
padding: 0 1.5rem 0;
}
/* ── Bubble (from article technique) ── */
.bubble {
position: relative;
max-width: 255px;
margin-bottom: 8px;
padding: 10px 20px;
line-height: 1.3;
word-wrap: break-word;
border-radius: 25px;
text-align: left;
white-space: pre-wrap;
font-size: 0.9rem;
transform: rotate(-2deg);
color: white;
background: var(--sent-color);
}
.bubble::before,
.bubble::after {
position: absolute;
bottom: 0;
height: 25px;
content: "";
}
.bubble::before {
width: 20px;
right: -7px;
background-color: var(--sent-color);
border-bottom-left-radius: 16px 14px;
}
.bubble::after {
width: 26px;
right: -26px;
border-bottom-left-radius: 10px;
background-color: var(--bg);
}
/* ── Share buttons ── */
.share-buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.share-btn {
width: 100%;
padding: 1rem 1.5rem;
font-size: 1.1rem;
font-weight: 700;
border: 3px solid rgba(0, 0, 0, 0.25);
border-radius: 1rem;
cursor: pointer;
transition:
transform 80ms ease,
box-shadow 80ms ease,
opacity 80ms ease;
letter-spacing: 0.01em;
}
.share-btn:active {
transform: scale(0.97);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.share-btn.primary {
background: #7c3aed;
color: white;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.4);
}
.share-btn.primary:hover {
opacity: 0.92;
}
.share-btn.primary.success {
background: #636363;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.share-btn.secondary {
background: #f2f2f7;
color: #1c1c1e;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.share-btn.secondary:hover {
background: #e5e5ea;
}
</style> </style>

View File

@@ -1,8 +1,3 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `$lib` alias in this folder.
export * from './utils/game'; export * from './utils/game';
export { default as VerseDisplay } from './components/VerseDisplay.svelte';
export { default as SearchInput } from './components/SearchInput.svelte';
export { default as GuessesTable } from './components/GuessesTable.svelte';
export { default as WinScreen } from './components/WinScreen.svelte';
export { default as Feedback } from './components/Feedback.svelte';

View 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
View 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;
}

View File

@@ -31,7 +31,7 @@ export async function validateSessionToken(token: string) {
const [result] = await db const [result] = await db
.select({ .select({
// Adjust user table here to tweak returned data // Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username }, user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId },
session: table.session session: table.session
}) })
.from(table.session) .from(table.session)
@@ -79,3 +79,83 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
path: '/' path: '/'
}); });
} }
export async function hashPassword(password: string): Promise<string> {
return await Bun.password.hash(password, {
algorithm: 'argon2id',
memoryCost: 4,
timeCost: 3
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await Bun.password.verify(password, hash);
} catch {
return false;
}
}
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
const user: table.User = {
id: anonymousId, // Use anonymousId as the user ID to preserve stats
email,
passwordHash,
appleId: null,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
};
await db.insert(table.user).values(user);
return user;
}
export async function getUserByEmail(email: string) {
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
return user || null;
}
export async function getUserByAppleId(appleId: string) {
const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId));
return user || null;
}
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
if (!anonymousId || anonymousId === userId) return;
try {
const { dailyCompletions } = await import('$lib/server/db/schema');
const anonCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId));
const userCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const userDates = new Set(userCompletions.map((c) => c.date));
let migrated = 0;
let skipped = 0;
for (const completion of anonCompletions) {
if (!userDates.has(completion.date)) {
await db
.update(dailyCompletions)
.set({ anonymousId: userId })
.where(eq(dailyCompletions.id, completion.id));
migrated++;
} else {
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id));
skipped++;
}
}
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
} catch (error) {
console.error('Error migrating anonymous stats:', error);
}
}

View 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;
}

View File

@@ -1,10 +1,9 @@
import { drizzle } from 'drizzle-orm/better-sqlite3'; import { drizzle } from 'drizzle-orm/bun-sqlite';
import Database from 'better-sqlite3'; import { Database } from 'bun:sqlite';
import * as schema from './schema'; import * as schema from './schema';
import { env } from '$env/dynamic/private';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); if (!Bun.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const client = new Database(env.DATABASE_URL); const client = new Database(Bun.env.DATABASE_URL);
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema });

View File

@@ -1,8 +1,15 @@
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core'; import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm'; 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', { export const session = sqliteTable('session', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -30,11 +37,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
anonymousId: text('anonymous_id').notNull(), anonymousId: text('anonymous_id').notNull(),
date: text('date').notNull(), date: text('date').notNull(),
guessCount: integer('guess_count').notNull(), guessCount: integer('guess_count').notNull(),
guesses: text('guesses'), // nullable; only stored for logged-in users
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(), completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({ }, (table) => [
uniqueCompletion: unique().on(table.anonymousId, table.date), index('anonymous_id_date_idx').on(table.anonymousId, table.date),
dateIndex: index('date_idx').on(table.date), index('date_idx').on(table.date),
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount), index('date_guess_idx').on(table.date, table.guessCount),
})); // Ensures schema matches the database migration and prevents duplicate submissions
unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
]);
export type DailyCompletion = typeof dailyCompletions.$inferSelect; export type DailyCompletion = typeof dailyCompletions.$inferSelect;

View 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 });

View 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,
};
}

View File

@@ -1,5 +1,13 @@
import { bibleBooks, type BibleBook } from '$lib/types/bible'; import { bibleBooks, type BibleBook } from '$lib/types/bible';
export interface Guess {
book: BibleBook;
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
firstLetterMatch: boolean;
}
export function getBookById(id: string): BibleBook | undefined { export function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id); return bibleBooks.find((b) => b.id === id);
} }
@@ -10,7 +18,47 @@ export function isAdjacent(id1: string, id2: string): boolean {
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1); return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
} }
export function getGrade(numGuesses: number, popularity: number): string { export function getFirstLetter(bookName: string): string {
const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
export function evaluateGuess(guessBookId: string, correctBookId: string): Guess | null {
const book = getBookById(guessBookId);
const correctBook = getBookById(correctBookId);
if (!book || !correctBook) return null;
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(guessBookId, correctBookId);
// Special case: if correct book is in the Epistles + starts with "1",
// any guess starting with "1" counts as first letter match
const correctIsEpistlesWithNumber =
(correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
correctBook.name[0] === "1";
const guessIsEpistlesWithNumber =
(book.section === "Pauline Epistles" ||
book.section === "General Epistles") &&
book.name[0] === "1";
const firstLetterMatch =
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true
: getFirstLetter(book.name).toUpperCase() ===
getFirstLetter(correctBook.name).toUpperCase();
return {
book,
testamentMatch,
sectionMatch,
adjacent,
firstLetterMatch,
};
}
export function getGrade(numGuesses: number): string {
if (numGuesses === 1) return "S+"; if (numGuesses === 1) return "S+";
if (numGuesses === 2) return "A+"; if (numGuesses === 2) return "A+";
if (numGuesses === 3) return "A"; if (numGuesses === 3) return "A";
@@ -49,4 +97,4 @@ export function generateUUID(): string {
const v = c === "x" ? r : (r & 0x3) | 0x8; const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16); return v.toString(16);
}); });
} }

63
src/lib/utils/share.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { Guess } from './game';
export function generateShareText(params: {
guesses: Guess[];
correctBookId: string;
dailyVerseDate: string;
chapterCorrect: boolean;
isLoggedIn: boolean;
streak?: number;
origin: string;
}): string {
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin } = 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 lines = [
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
`${guesses.length} ${guessWord}${streakPart}`,
];
lines.push(
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
origin,
);
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);
}

View 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;
}
}

View File

@@ -18,6 +18,21 @@ export interface UserStats {
guessCount: number; guessCount: number;
grade: string; grade: string;
}>; }>;
worstDay: {
date: string;
guessCount: number;
} | null;
bestBook: {
bookId: string;
avgGuesses: number;
count: number;
} | null;
mostSeenBook: {
bookId: string;
count: number;
} | null;
totalBooksSeenOT: number;
totalBooksSeenNT: number;
} }
export function getGradeColor(grade: string): string { export function getGradeColor(grade: string): string {

15
src/lib/utils/streak.ts Normal file
View 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;
}

View File

@@ -21,11 +21,6 @@
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<!-- <script <link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
defer
src="https://umami.snail.city/script.js"
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
data-domains="bibdle.com,www.bibdle.com"
></script> -->
</svelte:head> </svelte:head>
{@render children()} {@render children()}

View File

@@ -1,47 +1,14 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema'; import { dailyCompletions } from '$lib/server/db/schema';
import { eq, sql, asc } from 'drizzle-orm'; import { eq, asc } from 'drizzle-orm';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { fetchRandomVerse } from '$lib/server/bible-api';
import { getBookById } from '$lib/server/bible';
import type { DailyVerse } from '$lib/server/db/schema';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
async function getTodayVerse(): Promise<DailyVerse> { export const load: PageServerLoad = async ({ locals }) => {
// Get the current date (server-side)
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
// If there's an existing verse for the current date, return it
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
if (existing.length > 0) {
return existing[0];
}
// Otherwise get a new random verse
const apiVerse = await fetchRandomVerse();
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
const newVerse: Omit<DailyVerse, 'createdAt'> = {
id: crypto.randomUUID(),
date: dateStr,
bookId: apiVerse.bookId,
verseText: apiVerse.verseText,
reference: apiVerse.reference,
};
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
return inserted;
}
export const load: PageServerLoad = async () => {
const dailyVerse = await getTodayVerse();
const correctBook = getBookById(dailyVerse.bookId) ?? null;
return { return {
dailyVerse, user: locals.user,
correctBookId: dailyVerse.bookId, session: locals.session
correctBook
}; };
}; };
@@ -91,13 +58,20 @@ export const actions: Actions = {
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length; const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1; const guessRank = betterGuesses + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses // Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0); const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10; const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
// Percentile: what percentage of people you beat (100 - your rank percentage)
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
return { return {
success: true, success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses } stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
}; };
} }
}; };

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible";
import type { PageProps } from "./$types"; import type { PageProps } from "./$types";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { enhance } from "$app/forms";
import VerseDisplay from "$lib/components/VerseDisplay.svelte"; import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte"; import SearchInput from "$lib/components/SearchInput.svelte";
@@ -11,42 +10,49 @@
import Credits from "$lib/components/Credits.svelte"; import Credits from "$lib/components/Credits.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte"; import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import DevButtons from "$lib/components/DevButtons.svelte"; import DevButtons from "$lib/components/DevButtons.svelte";
import { getGrade } from "$lib/utils/game"; import AuthModal from "$lib/components/AuthModal.svelte";
interface Guess { import { evaluateGuess } from "$lib/utils/game";
book: BibleBook; import {
testamentMatch: boolean; generateShareText,
sectionMatch: boolean; shareResult,
adjacent: boolean; copyToClipboard as clipboardCopy,
firstLetterMatch: boolean; } from "$lib/utils/share";
} import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
import {
submitCompletion,
fetchExistingStats,
type StatsData,
} from "$lib/utils/stats-client";
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let dailyVerse = $derived(data.dailyVerse); let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId); let correctBookId = $derived(data.correctBookId);
let correctBook = $derived(data.correctBook);
let guesses = $state<Guess[]>([]); let user = $derived(data.user);
let session = $derived(data.session);
let searchQuery = $state(""); let searchQuery = $state("");
let copied = $state(false); let copied = $state(false);
let isDev = $state(false); let isDev = $state(false);
let chapterGuessCompleted = $state(false); let authModalOpen = $state(false);
let chapterCorrect = $state(false); let showWinScreen = $state(false);
let statsData = $state<StatsData | null>(null);
let streak = $state(0);
let streakPercentile = $state<number | null>(null);
let anonymousId = $state(""); const persistence = createGamePersistence(
let statsSubmitted = $state(false); () => dailyVerse.date,
let statsData = $state<{ () => dailyVerse.reference,
solveRank: number; () => correctBookId,
guessRank: number; () => user,
totalSolves: number; );
averageGuesses: number;
tiedCount: number;
percentile: number;
} | null>(null);
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id))); let guessedIds = $derived(
new Set(persistence.guesses.map((g) => g.book.id)),
);
const currentDate = $derived( const currentDate = $derived(
new Date().toLocaleDateString("en-US", { new Date().toLocaleDateString("en-US", {
@@ -57,351 +63,183 @@
}), }),
); );
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId)); let isWon = $derived(
let grade = $derived( persistence.guesses.some((g) => g.book.id === correctBookId),
isWon
? guesses.length === 1 && chapterCorrect
? "S++"
: getGrade(
guesses.length,
getBookById(correctBookId)?.popularity ?? 0,
)
: "",
); );
let blurChapter = $derived( let blurChapter = $derived(
isWon && guesses.length === 1 && !chapterGuessCompleted, isWon &&
persistence.guesses.length === 1 &&
!persistence.chapterGuessCompleted,
); );
function getBookById(id: string): BibleBook | undefined { async function submitGuess(bookId: string) {
return bibleBooks.find((b) => b.id === id); if (persistence.guesses.some((g) => g.book.id === bookId)) return;
}
function isAdjacent(id1: string, id2: string): boolean { const guess = evaluateGuess(bookId, correctBookId);
const b1 = getBookById(id1); if (!guess) return;
const b2 = getBookById(id2);
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
}
function submitGuess(bookId: string) { if (persistence.guesses.length === 0) {
if (guesses.some((g) => g.book.id === bookId)) return;
const book = getBookById(bookId);
if (!book) return;
const correctBook = getBookById(correctBookId);
if (!correctBook) return;
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(bookId, correctBookId);
// Special case: if correct book is in the Epistles + starts with "1",
// any guess starting with "1" counts as first letter match
const correctIsEpistlesWithNumber =
correctBook.section === "Pauline Epistles" &&
correctBook.name[0] === "1";
const guessStartsWithNumber = book.name[0] === "1";
const firstLetterMatch =
correctIsEpistlesWithNumber && guessStartsWithNumber
? true
: book.name[0].toUpperCase() ===
correctBook.name[0].toUpperCase();
console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
);
if (guesses.length === 0) {
const key = `bibdle-first-guess-${dailyVerse.date}`; const key = `bibdle-first-guess-${dailyVerse.date}`;
if ( if (
localStorage.getItem(key) !== "true" &&
browser && browser &&
localStorage.getItem(key) !== "true" &&
(window as any).umami (window as any).umami
) { ) {
(window as any).umami.track("First guess"); (window as any).umami.track("First guess");
(window as any).rybbit?.event("First guess");
localStorage.setItem(key, "true"); localStorage.setItem(key, "true");
} }
} }
guesses = [ persistence.guesses = [guess, ...persistence.guesses];
{
book,
testamentMatch,
sectionMatch,
adjacent,
firstLetterMatch,
},
...guesses,
];
searchQuery = ""; searchQuery = "";
}
function generateUUID(): string { if (
// Try native randomUUID if available guess.book.id === correctBookId &&
if (typeof window.crypto.randomUUID === "function") { browser &&
return window.crypto.randomUUID(); persistence.anonymousId
} ) {
statsData = await submitCompletion({
// Fallback UUID v4 generator for older browsers anonymousId: persistence.anonymousId,
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { date: dailyVerse.date,
const r = guessCount: persistence.guesses.length,
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0; guesses: persistence.guesses.map((g) => g.book.id),
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 (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(() => { $effect(() => {
if (!browser) return; if (!browser) return;
localStorage.setItem( isDev =
`bibdle-guesses-${dailyVerse.date}`, window.location.host === "localhost:5173" ||
JSON.stringify(guesses.map((g) => g.book.id)), 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(() => { $effect(() => {
console.log("Stats effect triggered:", { if (
browser, !browser ||
isWon, !isWon ||
anonymousId, !persistence.anonymousId ||
statsSubmitted, statsData ||
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(() => { $effect(() => {
if (!browser || !isWon) return; if (!browser || !isWon) return;
const key = `bibdle-win-tracked-${dailyVerse.date}`; const isNew = persistence.markWinTracked();
if (localStorage.getItem(key) === "true") return; if (isNew && (window as any).umami) {
if ((window as any).umami) {
(window as any).umami.track("Guessed correctly", { (window as any).umami.track("Guessed correctly", {
totalGuesses: guesses.length, totalGuesses: persistence.guesses.length,
});
(window as any).rybbit?.event("Guessed correctly", {
totalGuesses: persistence.guesses.length,
}); });
} }
localStorage.setItem(key, "true");
}); });
function generateShareText(): string { // Fetch streak when the player wins
const emojis = guesses $effect(() => {
.slice() if (!browser || !isWon || !persistence.anonymousId) return;
.reverse() const localDate = new Date().toLocaleDateString("en-CA");
.map((guess) => { fetchStreak(persistence.anonymousId, localDate).then((result) => {
if (guess.book.id === correctBookId) return "✅"; streak = result;
if (guess.adjacent) return "‼️"; if (result >= 2) {
if (guess.sectionMatch) return "🟩"; fetchStreakPercentile(result, localDate).then((p) => {
if (guess.testamentMatch) return "🟧"; streakPercentile = p;
return "🟥"; });
})
.join("");
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const formattedDate = dateFormatter.format(
new Date(`${dailyVerse.date}T00:00:00`),
);
const siteUrl = window.location.origin;
return [
`📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
siteUrl,
].join("\n");
}
async function share() {
if (!browser) return;
const shareText = generateShareText();
try {
if ("share" in navigator) {
await (navigator as any).share({ text: shareText });
} else {
await (navigator as any).clipboard.writeText(shareText);
} }
} catch (err) { });
console.error("Share failed:", err); });
throw err;
}
}
async function copyToClipboard() { function getShareText(): string {
if (!browser) return; return generateShareText({
guesses: persistence.guesses,
const shareText = generateShareText(); correctBookId,
dailyVerseDate: dailyVerse.date,
try { chapterCorrect: persistence.chapterCorrect,
await (navigator as any).clipboard.writeText(shareText); isLoggedIn: !!user,
copied = true; streak,
setTimeout(() => { origin: window.location.origin,
copied = false; });
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
throw err;
}
} }
function handleShare() { function handleShare() {
@@ -410,7 +248,7 @@
if (useClipboard) { if (useClipboard) {
copied = true; copied = true;
} }
share() shareResult(getShareText())
.then(() => { .then(() => {
if (useClipboard) { if (useClipboard) {
setTimeout(() => { setTimeout(() => {
@@ -424,81 +262,177 @@
} }
}); });
} }
async function handleCopyToClipboard() {
if (!browser) return;
try {
await clipboardCopy(getShareText());
copied = true;
setTimeout(() => {
copied = false;
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
}
}
</script> </script>
<svelte:head> <svelte:head>
<!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title> <title>A daily bible game{isDev ? " (dev)" : ""}</title>
<!-- <meta
name="description"
content="Guess which book of the Bible a verse comes from."
/> -->
</svelte:head> </svelte:head>
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8"> <div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
<div class="w-full max-w-3xl mx-auto px-4"> <div class="w-full max-w-3xl mx-auto px-4">
<h1 <h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4" class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4 animate-fade-in-up"
> >
<TitleAnimation /> <TitleAnimation />
<div class="font-normal"></div> <div class="font-normal"></div>
</h1> </h1>
<div class="text-center mb-8"> <div class="text-center mb-8 animate-fade-in-up animate-delay-200">
<span class="big-text" <span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span >{isDev ? "Dev Edition | " : ""}{currentDate}</span
> >
<div class="mt-4">
<a
href="/stats?anonymousId={anonymousId}"
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
>
📊 View Stats
</a>
</div>
</div> </div>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<VerseDisplay {data} {isWon} {blurChapter} /> <div class="animate-fade-in-up animate-delay-200">
<VerseDisplay {data} {isWon} {blurChapter} />
</div>
{#if !isWon} {#if !isWon}
<SearchInput bind:searchQuery {guessedIds} {submitGuess} /> <div class="animate-fade-in-up animate-delay-400">
{:else} <SearchInput bind:searchQuery {guessedIds} {submitGuess} />
<WinScreen </div>
{grade} {:else if showWinScreen}
{statsData} <div class="animate-fade-in-up animate-delay-400">
{correctBookId} <WinScreen
{handleShare} {statsData}
{copyToClipboard} {correctBookId}
bind:copied {handleShare}
{statsSubmitted} copyToClipboard={handleCopyToClipboard}
guessCount={guesses.length} bind:copied
reference={dailyVerse.reference} statsSubmitted={persistence.statsSubmitted}
onChapterGuessCompleted={() => { guessCount={persistence.guesses.length}
chapterGuessCompleted = true; reference={dailyVerse.reference}
const key = `bibdle-chapter-guess-${dailyVerse.reference}`; onChapterGuessCompleted={persistence.onChapterGuessCompleted}
const saved = localStorage.getItem(key); shareText={getShareText()}
if (saved) { {streak}
const data = JSON.parse(saved); {streakPercentile}
const match = />
dailyVerse.reference.match(/\s(\d+):/); </div>
const correctChapter = match
? parseInt(match[1], 10)
: 1;
chapterCorrect =
data.selectedChapter === correctChapter;
}
}}
/>
{/if} {/if}
<GuessesTable {guesses} {correctBookId} /> <div class="animate-fade-in-up animate-delay-600">
<GuessesTable guesses={persistence.guesses} {correctBookId} />
</div>
{#if isWon} {#if isWon}
<Credits /> <div class="animate-fade-in-up animate-delay-800">
<Credits />
</div>
{/if} {/if}
</div> </div>
{#if isDev} {#if isDev}
<DevButtons /> <div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
<div class="flex flex-col md:flex-row gap-3">
<a
href="/stats?{user
? `userId=${user.id}`
: `anonymousId=${persistence.anonymousId}`}&tz={encodeURIComponent(
Intl.DateTimeFormat().resolvedOptions().timeZone,
)}"
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
>
📊 View Stats
</a>
{#if user}
<form
method="POST"
action="/auth/logout"
use:enhance
class="w-full md:w-auto"
>
<button
type="submit"
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
>
🚪 Sign Out
</button>
</form>
{:else}
<button
onclick={() => (authModalOpen = true)}
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
>
🔐 Sign In
</button>
{/if}
</div>
<div
class="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded border"
>
<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 text-center text-xs text-gray-400"
>
Signed in as {[user.firstName, user.lastName]
.filter(Boolean)
.join(" ")}{user.email
? ` (${user.email})`
: ""}{user.appleId ? " using Apple" : ""} |
<form
method="POST"
action="/auth/logout"
use:enhance
class="inline"
>
<button
type="submit"
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
>Sign out</button
>
</form>
</div>
{/if} {/if}
</div> </div>
</div> </div>
<AuthModal bind:isOpen={authModalOpen} anonymousId={persistence.anonymousId} />

23
src/routes/+page.ts Normal file
View 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,
};
};

View 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,
});
};

View File

@@ -0,0 +1,66 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { json } from '@sveltejs/kit';
import crypto from 'node:crypto';
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
// A spread of book IDs to use as fake guesses
const SAMPLE_BOOK_IDS = [
'GEN', 'EXO', 'PSA', 'PRO', 'ISA', 'JER', 'MAT', 'MRK', 'LUK', 'JHN',
'ROM', 'GAL', 'EPH', 'PHP', 'REV', 'ACT', 'HEB', 'JAS', '1CO', '2CO',
];
export const POST: RequestHandler = async ({ request }) => {
const host = request.headers.get('host') ?? '';
if (!DEV_HOSTS.includes(host)) {
return json({ error: 'Not allowed in production' }, { status: 403 });
}
try {
const { anonymousId } = await request.json();
if (!anonymousId || typeof anonymousId !== 'string') {
return json({ error: 'anonymousId required' }, { status: 400 });
}
const today = new Date();
const inserted: string[] = [];
const skipped: string[] = [];
for (let i = 1; i <= 10; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
const guessCount = Math.floor(Math.random() * 6) + 1; // 1-6 guesses
// Pick `guessCount` random books (last one is the "correct" answer)
const shuffled = [...SAMPLE_BOOK_IDS].sort(() => Math.random() - 0.5);
const guesses = shuffled.slice(0, guessCount);
try {
await db.insert(dailyCompletions).values({
id: crypto.randomUUID(),
anonymousId,
date,
guessCount,
guesses: JSON.stringify(guesses),
completedAt: new Date(d.getTime() + 12 * 60 * 60 * 1000), // noon on that day
});
inserted.push(date);
} catch (err: any) {
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
skipped.push(date);
} else {
throw err;
}
}
}
return json({ success: true, inserted, skipped });
} catch (err) {
console.error('Error seeding history:', err);
return json({ error: 'Failed to seed history' }, { status: 500 });
}
};

View 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 });
}
};

View 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 });
};

View 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 });
};

View File

@@ -1,13 +1,13 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema'; import { dailyCompletions } from '$lib/server/db/schema';
import { and, eq, asc } from 'drizzle-orm'; import { eq, asc } from 'drizzle-orm';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
try { try {
const { anonymousId, date, guessCount } = await request.json(); const { anonymousId, date, guessCount, guesses } = await request.json();
// Validation // Validation
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) { if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
anonymousId, anonymousId,
date, date,
guessCount, guessCount,
guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
completedAt, completedAt,
}); });
} catch (err: any) { } catch (err: any) {
@@ -44,11 +45,9 @@ export const POST: RequestHandler = async ({ request }) => {
// Solve rank: position in time-ordered list // Solve rank: position in time-ordered list
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1; const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking) // Guess rank: count how many had FEWER guesses (ties get same rank)
const uniqueBetterGuessCounts = new Set( const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount) const guessRank = betterGuesses + 1;
);
const guessRank = uniqueBetterGuessCounts.size + 1;
// Count ties: how many have the SAME guessCount (excluding self) // Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length; const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
@@ -70,69 +69,3 @@ export const POST: RequestHandler = async ({ request }) => {
return json({ error: 'Failed to submit completion' }, { status: 500 }); return json({ error: 'Failed to submit completion' }, { status: 500 });
} }
}; };
export const GET: RequestHandler = async ({ url }) => {
try {
const anonymousId = url.searchParams.get('anonymousId');
const date = url.searchParams.get('date');
if (!anonymousId || !date) {
return json({ error: 'Invalid data' }, { status: 400 });
}
const userCompletions = await db
.select()
.from(dailyCompletions)
.where(and(
eq(dailyCompletions.anonymousId, anonymousId),
eq(dailyCompletions.date, date)
))
.limit(1);
if (userCompletions.length === 0) {
return json({ error: 'No completion found' }, { status: 404 });
}
const userCompletion = userCompletions[0];
const guessCount = userCompletion.guessCount;
// Calculate statistics
const allCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.date, date))
.orderBy(asc(dailyCompletions.completedAt));
const totalSolves = allCompletions.length;
// Solve rank: position in time-ordered list
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
const uniqueBetterGuessCounts = new Set(
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
);
const guessRank = uniqueBetterGuessCounts.size + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
// Percentile: what percentage of people you beat (100 - your rank percentage)
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
return json({
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
});
} catch (err) {
console.error('Error fetching stats:', err);
return json({ error: 'Failed to fetch stats' }, { status: 500 });
}
};

View 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));
}
};

View 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, '/');
};

View File

@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
return {
user: locals.user,
session: locals.session
};
};

View 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>

View 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, '/');
}
};

View 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' });
}
}
};

View 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' });
}
}
};

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// 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 });
}
};

View File

@@ -16,4 +16,36 @@ html, body {
letter-spacing: 0.2em; letter-spacing: 0.2em;
color: rgb(107 114 128); color: rgb(107 114 128);
font-weight: 700; font-weight: 700;
}
/* 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;
} }

View File

@@ -1,24 +1,42 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema'; import { dailyCompletions, dailyVerses, type DailyCompletion } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { bibleBooks } from '$lib/types/bible';
export const load: PageServerLoad = async ({ url }) => { export const load: PageServerLoad = async ({ url, locals }) => {
const anonymousId = url.searchParams.get('anonymousId'); // Check if user is authenticated
if (!locals.user) {
if (!anonymousId) {
return { return {
stats: null, stats: null,
error: 'No anonymous ID provided' error: null,
user: null,
session: null,
requiresAuth: true
}; };
} }
const userId = locals.user.id;
if (!userId) {
return {
stats: null,
error: 'No user ID provided',
user: locals.user,
session: locals.session
};
}
// Get user's current date from timezone query param
const timezone = url.searchParams.get('tz') || 'UTC';
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
try { try {
// Get all completions for this user // Get all completions for this user
const completions = await db const completions = await db
.select() .select()
.from(dailyCompletions) .from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId)) .where(eq(dailyCompletions.anonymousId, userId))
.orderBy(desc(dailyCompletions.date)); .orderBy(desc(dailyCompletions.date));
if (completions.length === 0) { if (completions.length === 0) {
@@ -38,8 +56,15 @@ export const load: PageServerLoad = async ({ url }) => {
}, },
currentStreak: 0, currentStreak: 0,
bestStreak: 0, bestStreak: 0,
recentCompletions: [] recentCompletions: [],
} worstDay: null,
bestBook: null,
mostSeenBook: null,
totalBooksSeenOT: 0,
totalBooksSeenNT: 0
},
user: locals.user,
session: locals.session
}; };
} }
@@ -64,26 +89,29 @@ export const load: PageServerLoad = async ({ url }) => {
const sortedDates = completions const sortedDates = completions
.map((c: DailyCompletion) => c.date) .map((c: DailyCompletion) => c.date)
.sort(); .sort();
let currentStreak = 0; let currentStreak = 0;
let bestStreak = 0; let bestStreak = 0;
let tempStreak = 1; let tempStreak = 1;
if (sortedDates.length > 0) { if (sortedDates.length > 0) {
// Check if current streak is active (includes today or yesterday) // Check if current streak is active (includes today or yesterday)
const today = new Date().toISOString().split('T')[0]; // Use the user's local date passed from the client
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const today = userToday;
const yesterdayDate = new Date(userToday);
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = yesterdayDate.toISOString().split('T')[0];
const lastPlayedDate = sortedDates[sortedDates.length - 1]; const lastPlayedDate = sortedDates[sortedDates.length - 1];
if (lastPlayedDate === today || lastPlayedDate === yesterday) { if (lastPlayedDate === today || lastPlayedDate === yesterday) {
currentStreak = 1; currentStreak = 1;
// Count backwards from the most recent date // Count backwards from the most recent date
for (let i = sortedDates.length - 2; i >= 0; i--) { for (let i = sortedDates.length - 2; i >= 0; i--) {
const currentDate = new Date(sortedDates[i + 1]); const currentDate = new Date(sortedDates[i + 1]);
const prevDate = new Date(sortedDates[i]); const prevDate = new Date(sortedDates[i]);
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)); const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) { if (daysDiff === 1) {
currentStreak++; currentStreak++;
} else { } else {
@@ -91,14 +119,14 @@ export const load: PageServerLoad = async ({ url }) => {
} }
} }
} }
// Calculate best streak // Calculate best streak
bestStreak = 1; bestStreak = 1;
for (let i = 1; i < sortedDates.length; i++) { for (let i = 1; i < sortedDates.length; i++) {
const currentDate = new Date(sortedDates[i]); const currentDate = new Date(sortedDates[i]);
const prevDate = new Date(sortedDates[i - 1]); const prevDate = new Date(sortedDates[i - 1]);
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)); const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) { if (daysDiff === 1) {
tempStreak++; tempStreak++;
} else { } else {
@@ -118,6 +146,66 @@ export const load: PageServerLoad = async ({ url }) => {
grade: getGradeFromGuesses(c.guessCount) grade: getGradeFromGuesses(c.guessCount)
})); }));
// Calculate worst day (highest guess count)
const worstDay = completions.reduce((max, c) =>
c.guessCount > max.guessCount ? c : max,
completions[0]
);
// Get all daily verses to link completions to books
const allVerses = await db
.select()
.from(dailyVerses);
// Create a map of date -> bookId
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
// Calculate book-specific stats
const bookStats = new Map<string, { count: number; totalGuesses: number }>();
for (const completion of completions) {
const bookId = dateToBookId.get(completion.date);
if (bookId) {
const existing = bookStats.get(bookId) || { count: 0, totalGuesses: 0 };
bookStats.set(bookId, {
count: existing.count + 1,
totalGuesses: existing.totalGuesses + completion.guessCount
});
}
}
// Find book you know the best (lowest avg guesses)
let bestBook: { bookId: string; avgGuesses: number; count: number } | null = null;
for (const [bookId, stats] of bookStats.entries()) {
const avgGuesses = stats.totalGuesses / stats.count;
if (!bestBook || avgGuesses < bestBook.avgGuesses) {
bestBook = { bookId, avgGuesses, count: stats.count };
}
}
// Find most seen book
let mostSeenBook: { bookId: string; count: number } | null = null;
for (const [bookId, stats] of bookStats.entries()) {
if (!mostSeenBook || stats.count > mostSeenBook.count) {
mostSeenBook = { bookId, count: stats.count };
}
}
// Count unique books by testament
const oldTestamentBooks = new Set<string>();
const newTestamentBooks = new Set<string>();
for (const [bookId, _] of bookStats.entries()) {
const book = bibleBooks.find(b => b.id === bookId);
if (book) {
if (book.testament === 'old') {
oldTestamentBooks.add(bookId);
} else {
newTestamentBooks.add(bookId);
}
}
}
return { return {
stats: { stats: {
totalSolves, totalSolves,
@@ -125,15 +213,34 @@ export const load: PageServerLoad = async ({ url }) => {
gradeDistribution, gradeDistribution,
currentStreak, currentStreak,
bestStreak, bestStreak,
recentCompletions recentCompletions,
} worstDay: {
date: worstDay.date,
guessCount: worstDay.guessCount
},
bestBook: bestBook ? {
bookId: bestBook.bookId,
avgGuesses: Math.round(bestBook.avgGuesses * 100) / 100,
count: bestBook.count
} : null,
mostSeenBook: mostSeenBook ? {
bookId: mostSeenBook.bookId,
count: mostSeenBook.count
} : null,
totalBooksSeenOT: oldTestamentBooks.size,
totalBooksSeenNT: newTestamentBooks.size
},
user: locals.user,
session: locals.session
}; };
} catch (error) { } catch (error) {
console.error('Error fetching user stats:', error); console.error('Error fetching user stats:', error);
return { return {
stats: null, stats: null,
error: 'Failed to fetch stats' error: 'Failed to fetch stats',
user: locals.user,
session: locals.session
}; };
} }
}; };
@@ -146,4 +253,4 @@ function getGradeFromGuesses(guessCount: number): string {
if (guessCount >= 7 && guessCount <= 10) return "B"; if (guessCount >= 7 && guessCount <= 10) return "B";
if (guessCount >= 11 && guessCount <= 15) return "C+"; if (guessCount >= 11 && guessCount <= 15) return "C+";
return "C"; return "C";
} }

View File

@@ -2,20 +2,29 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { import { enhance } from "$app/forms";
getGradeColor, import AuthModal from "$lib/components/AuthModal.svelte";
formatDate, import Container from "$lib/components/Container.svelte";
getStreakMessage, import { bibleBooks } from "$lib/types/bible";
import {
getGradeColor,
formatDate,
getStreakMessage,
getPerformanceMessage, getPerformanceMessage,
type UserStats type UserStats,
} from "$lib/utils/stats"; } from "$lib/utils/stats";
interface PageData { interface PageData {
stats: UserStats | null; stats: UserStats | null;
error?: string; error?: string;
user?: any;
session?: any;
requiresAuth?: boolean;
} }
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let anonymousId = $state("");
let loading = $state(true); let loading = $state(true);
@@ -31,20 +40,7 @@
} }
onMount(async () => { onMount(async () => {
const anonymousId = getOrCreateAnonymousId(); anonymousId = getOrCreateAnonymousId();
if (!anonymousId) {
goto("/");
return;
}
// If no anonymousId in URL, redirect with it
const url = new URL(window.location.href);
if (!url.searchParams.get('anonymousId')) {
url.searchParams.set('anonymousId', anonymousId);
goto(url.pathname + url.search);
return;
}
loading = false; loading = false;
}); });
@@ -52,41 +48,83 @@
return total > 0 ? Math.round((count / total) * 100) : 0; 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); $inspect(data);
</script> </script>
<svelte:head> <svelte:head>
<title>Stats | Bibdle</title> <title>Stats | Bibdle</title>
<meta name="description" content="View your Bibdle game statistics and performance" /> <meta
name="description"
content="View your Bibdle game statistics and performance"
/>
</svelte:head> </svelte:head>
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4"> <div
<div class="max-w-4xl mx-auto"> class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
>
<div class="max-w-6xl mx-auto">
<!-- Header --> <!-- Header -->
<div class="text-center mb-8"> <div class="text-center mb-6 md:mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1> <h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
<p class="text-gray-600">Track your Bibdle performance over time</p> Your Stats
<div class="mt-4"> </h1>
<a <p class="text-sm md:text-base text-gray-300 mb-4">
href="/" Track your Bibdle performance over time
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors" </p>
> <a
← Back to Game href="/"
</a> 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"
</div> >
← Back to Game
</a>
</div> </div>
{#if loading} {#if loading}
<div class="text-center py-12"> <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> <div
<p class="mt-4 text-gray-600">Loading your stats...</p> 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> </div>
{:else if data.error} {:else if data.error}
<div class="text-center py-12"> <div class="text-center py-12">
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto"> <div
<p class="text-red-700">{data.error}</p> class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
<a >
href="/" <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" class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
> >
Return to Game Return to Game
@@ -95,112 +133,285 @@
</div> </div>
{:else if !data.stats} {:else if !data.stats}
<div class="text-center py-12"> <div class="text-center py-12">
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto"> <Container class="p-8 max-w-md mx-auto">
<p class="text-yellow-700">No stats available.</p> <div class="text-yellow-400 mb-4 text-lg">
<a No stats available yet.
href="/" </div>
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors" <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 Start Playing
</a> </a>
</div> </Container>
</div> </div>
{:else} {:else}
{@const stats = data.stats} {@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">
<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>
</div>
<!-- Average Guesses -->
<div class="bg-white rounded-lg shadow-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>
<!-- Key Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
<!-- Current Streak --> <!-- Current Streak -->
<div class="bg-white rounded-lg shadow-md p-6"> <Container class="p-4 md:p-6">
<div class="text-center"> <div class="text-center">
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div> <div class="text-2xl md:text-3xl mb-1">🔥</div>
<div class="text-gray-600">Current Streak</div> <div
<div class="text-sm text-gray-500 mt-1"> class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
{getStreakMessage(stats.currentStreak)} >
{stats.currentStreak}
</div>
<div
class="text-xs md:text-sm text-gray-300 font-medium"
>
Current Streak
</div> </div>
</div> </div>
</div> </Container>
<!-- Longest Streak -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1"></div>
<div
class="text-2xl md:text-3xl font-bold text-purple-400 mb-1"
>
{stats.bestStreak}
</div>
<div
class="text-xs md:text-sm text-gray-300 font-medium"
>
Best Streak
</div>
</div>
</Container>
<!-- Average Guesses -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1">🎯</div>
<div
class="text-2xl md:text-3xl font-bold text-blue-400 mb-1"
>
{stats.avgGuesses}
</div>
<div
class="text-xs md:text-sm text-gray-300 font-medium"
>
Avg Guesses
</div>
</div>
</Container>
<!-- Total Solves -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1"></div>
<div
class="text-2xl md:text-3xl font-bold text-green-400 mb-1"
>
{stats.totalSolves}
</div>
<div
class="text-xs md:text-sm text-gray-300 font-medium"
>
Total Solves
</div>
</div>
</Container>
</div> </div>
<!-- Grade Distribution -->
{#if stats.totalSolves > 0} {#if stats.totalSolves > 0}
<div class="bg-white rounded-lg shadow-md p-6 mb-8"> <!-- Book Stats Grid -->
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2> <div
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6"
{#each Object.entries(stats.gradeDistribution) as [grade, count]} >
{@const percentage = getGradePercentage(count, stats.totalSolves)} <!-- Worst Day -->
{#if stats.worstDay}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">😅</div>
<div class="flex-1 min-w-0">
<div
class="text-sm md:text-base text-gray-300 font-medium mb-1"
>
Worst Day
</div>
<div
class="text-xl md:text-2xl font-bold text-red-400 truncate"
>
{stats.worstDay.guessCount} guesses
</div>
<div
class="text-xs md:text-sm text-gray-400"
>
{formatDate(stats.worstDay.date)}
</div>
</div>
</div>
</Container>
{/if}
<!-- Best Book -->
{#if stats.bestBook}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">🏆</div>
<div class="flex-1 min-w-0">
<div
class="text-sm md:text-base text-gray-300 font-medium mb-1"
>
Best Book
</div>
<div
class="text-lg md:text-xl font-bold text-amber-400 truncate"
>
{getBookName(stats.bestBook.bookId)}
</div>
<div
class="text-xs md:text-sm text-gray-400"
>
{stats.bestBook.avgGuesses} avg guesses ({stats
.bestBook.count}x)
</div>
</div>
</div>
</Container>
{/if}
<!-- Most Seen Book -->
{#if stats.mostSeenBook}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">📖</div>
<div class="flex-1 min-w-0">
<div
class="text-sm md:text-base text-gray-300 font-medium mb-1"
>
Most Seen Book
</div>
<div
class="text-lg md:text-xl font-bold text-indigo-400 truncate"
>
{getBookName(stats.mostSeenBook.bookId)}
</div>
<div
class="text-xs md:text-sm text-gray-400"
>
{stats.mostSeenBook.count} time{stats
.mostSeenBook.count === 1
? ""
: "s"}
</div>
</div>
</div>
</Container>
{/if}
<!-- Total Books Seen -->
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">📚</div>
<div class="flex-1 min-w-0">
<div
class="text-sm md:text-base text-gray-300 font-medium mb-1"
>
Unique Books
</div>
<div
class="text-xl md:text-2xl font-bold text-teal-400"
>
{stats.totalBooksSeenOT +
stats.totalBooksSeenNT}
</div>
<div class="text-xs md:text-sm text-gray-400">
OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}
</div>
</div>
</div>
</Container>
</div>
<!-- Grade Distribution -->
<Container class="p-5 md:p-6 mb-6">
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
Grade Distribution
</h2>
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
{@const percentage = getGradePercentage(
count,
stats.totalSolves,
)}
<div class="text-center"> <div class="text-center">
<div class="mb-2"> <div class="mb-2">
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}"> <span
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
grade,
)}"
>
{grade} {grade}
</span> </span>
</div> </div>
<div class="text-2xl font-bold text-gray-800">{count}</div> <div
<div class="text-sm text-gray-500">{percentage}%</div> class="text-lg md:text-2xl font-bold text-gray-100"
>
{count}
</div>
<div class="text-xs text-gray-400">
{percentage}%
</div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </Container>
<!-- 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 --> <!-- Recent Performance -->
{#if stats.recentCompletions.length > 0} {#if stats.recentCompletions.length > 0}
<div class="bg-white rounded-lg shadow-md p-6"> <Container class="p-5 md:p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Recent Performance</h2> <h2
<div class="space-y-3"> class="text-lg md:text-xl font-bold text-gray-100 mb-4"
{#each stats.recentCompletions as completion} >
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0"> Recent Performance
</h2>
<div class="space-y-2">
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
<div
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
>
<div> <div>
<span class="font-medium">{formatDate(completion.date)}</span> <span
class="text-sm md:text-base font-medium text-gray-200"
>{formatDate(completion.date)}</span
>
</div> </div>
<div class="flex items-center gap-3"> <div
<span class="text-gray-600">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span> class="flex items-center gap-2 md:gap-3"
<span class="px-2 py-1 rounded text-sm font-semibold {getGradeColor(completion.grade)}"> >
<span
class="text-xs md:text-sm text-gray-300"
>{completion.guessCount} guess{completion.guessCount ===
1
? ""
: "es"}</span
>
<span
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
completion.grade,
)}"
>
{completion.grade} {completion.grade}
</span> </span>
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </Container>
{/if} {/if}
{/if} {/if}
{/if} {/if}
</div> </div>
</div> </div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -7,7 +7,13 @@ const config = {
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { adapter: adapter() } kit: {
adapter: adapter(),
csrf: {
// Allow Apple Sign In cross-origin form_post callback
trustedOrigins: ['https://appleid.apple.com']
}
}
}; };
export default config; export default config;

View 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);
}
});
});

View 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());
});
});

View 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
});
});