Compare commits

51 Commits

Author SHA1 Message Date
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
7d93ead70c added demo stats page (needs refinement) 2026-02-04 23:35:23 -05:00
George Powell
4c82aa078b added identifying with umami anonymous ID 2026-02-03 23:43:03 -05:00
George Powell
2058149207 delayed loading of umami tracker script until page is already
interactive
2026-02-03 00:18:11 -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
2bd86d37a1 added svelte mcp 2026-01-29 01:12:18 -05:00
George Powell
33d6fae446 added .env.example 2026-01-29 01:12:09 -05:00
George Powell
ff228fb547 Update package.json version 2026-01-28 23:35:33 -05:00
George Powell
d21ca9d687 add percentile stats, update chapter guess UI 2026-01-28 23:03:51 -05:00
George Powell
2df97f66bf package upgrades 2026-01-28 16:06:01 -05:00
George Powell
b1420a3e4f Merge remote changes from github/main 2026-01-28 16:04:48 -05:00
George Powell
fe9cc09df6 added test dev buttons and email button 2026-01-28 16:02:52 -05:00
George Powell
55a9fd59ea fixed some epistle bugs with firstLetter, maybe. and wording 2026-01-28 15:15:40 -05:00
George Powell
0ee3d8a4d0 Revamped middle statline (ranking instead of arbitrary percentage) 2026-01-28 15:04:29 -05:00
George Powell
6365cfb363 Added instructions 2026-01-28 14:57:22 -05:00
George Powell
860839fd75 add deployment script 2026-01-26 23:45:52 -05:00
George Powell
e4b946ec8c package updates 2026-01-26 23:41:57 -05:00
George Powell
b80c18c2aa added function to measure correlation between ease of solving and number of players 2026-01-26 23:40:28 -05:00
George Powell
8c488d27df add First Letter column with special epistle handling 2026-01-26 23:31:24 -05:00
George Powell
77d6254a2c replace table with colored box grid for better visual feedback 2026-01-26 23:09:31 -05:00
George Powell
7fbed528f8 added bluesky profile link 2026-01-26 00:38:01 -05:00
George Powell
cec85be7c9 feat: Add Imposter game component and update project assets 2026-01-26 00:25:51 -05:00
George Powell
c50336ab5f feat: Add Claude settings to restrict access to sensitive files 2026-01-26 00:25:31 -05:00
George Powell
03645f0452 Merge remote-tracking branch 'github/main' 2026-01-05 18:21:08 -05:00
George Powell
cb11d793f6 replaced trailing punctuation in verses with ellipses 2026-01-05 18:15:30 -05:00
George Powell
ac1db94b0d replaced trailing punctuation in verses with ellipses 2026-01-05 18:12:10 -05:00
George Powell
1b1bc7bd3c Added chapter guess challenge 2026-01-04 16:36:28 -05:00
George Powell
0f6870344f major styling and spacing 2026-01-04 01:25:49 -05:00
55 changed files with 4424 additions and 1087 deletions

11
.claude/settings.json Normal file
View File

@@ -0,0 +1,11 @@
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./secrets/**)",
"Read(./config/credentials.json)",
"Read(./build)",
"Read(./embeddings**)"
]
}
}

23
.env.example Normal file
View File

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

4
.gitignore vendored
View File

@@ -27,6 +27,6 @@ vite.config.ts.timestamp-*
llms-* llms-*
embeddings*
*bible.xml
engwebu_usfx.xml engwebu_usfx.xml
embeddings-cache-L12.json
embeddings-cache-L6.json

View File

@@ -1,3 +0,0 @@
EnglishNKJBible.xml
GreekModern1904Bible.xml
engwebu_usfx.xml

View File

@@ -4,7 +4,33 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily. Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book, etc.) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
(Make sure you use the Svelte agent to execute these commands)
## Available MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 4. playground-link
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
## Tech Stack ## Tech Stack
@@ -19,23 +45,23 @@ Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read
```bash ```bash
# Start development server # Start development server
npm run dev bun run dev
# Type checking # Type checking
npm run check bun run check
npm run check:watch bun run check:watch
# Build for production # Build for production
npm run build bun run build
# Preview production build # Preview production build
npm run preview bun run preview
# Database operations # Database operations
npm run db:push # Push schema changes to database bun run db:push # Push schema changes to database
npm run db:generate # Generate migrations bun run db:generate # Generate migrations (DO NOT RUN)
npm run db:migrate # Run migrations bun run db:migrate # Run migrations (DO NOT RUN)
npm run db:studio # Open Drizzle Studio GUI bun run db:studio # Open Drizzle Studio GUI
``` ```
## Architecture ## Architecture

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>
@@ -25056,7 +25056,7 @@
<chapter number="3"> <chapter number="3">
<verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse> <verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse>
<verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiners fire And like launderers soap.</verse> <verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiners fire And like launderers soap.</verse>
<verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, And purge them as gold and silver, That they may offer to the LordAn offering in righteousness.</verse> <verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, and purge them as gold and silver, that they may offer to the Lord an offering in righteousness.</verse>
<verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse> <verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse>
<verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse> <verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse>
<verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse> <verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse>
@@ -33616,4 +33616,4 @@
</chapter> </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.

31
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

View File

@@ -6,27 +6,26 @@
"name": "bibdle", "name": "bibdle",
"dependencies": { "dependencies": {
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.5.0",
"fast-xml-parser": "^5.3.3", "fast-xml-parser": "^5.3.3",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
}, },
"devDependencies": { "devDependencies": {
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.8",
"@types/node": "^22", "@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.1",
"svelte": "^5.45.6", "svelte": "^5.48.5",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.5",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6", "vite": "^7.3.1",
}, },
}, },
}, },
@@ -187,11 +186,11 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.4.0", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ=="], "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.2", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q=="],
"@sveltejs/kit": ["@sveltejs/kit@2.49.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ=="], "@sveltejs/kit": ["@sveltejs/kit@2.50.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="], "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
@@ -229,13 +228,15 @@
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], "@types/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=="],
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], "@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@@ -263,7 +264,7 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="], "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
@@ -273,6 +274,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "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=="],
@@ -303,7 +306,7 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="], "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
@@ -421,6 +424,8 @@
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], "node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onnx-proto": ["onnx-proto@4.0.4", "", { "dependencies": { "protobufjs": "^6.8.8" } }, "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA=="], "onnx-proto": ["onnx-proto@4.0.4", "", { "dependencies": { "protobufjs": "^6.8.8" } }, "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA=="],
@@ -497,9 +502,9 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.46.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw=="], "svelte": ["svelte@5.48.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NB3o70OxfmnE5UPyLr8uH3IV02Q43qJVAuWigYmsSOYsS0s/rHxP0TF81blG0onF/xkhNvZw4G8NfzIX+By5ZQ=="],
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="], "svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@@ -523,7 +528,7 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
@@ -551,8 +556,12 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/better-sqlite3/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
"prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"protobufjs/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
"vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], "vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],

20
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

34
daily_completions_report.sh Executable file
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';"

18
deploy.sh Executable file
View File

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

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

@@ -8,6 +8,13 @@
"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
} }
] ]
} }

View File

@@ -1,15 +1,17 @@
{ {
"name": "bibdle", "name": "bibdle",
"private": true, "private": true,
"version": "0.0.1", "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",
@@ -18,24 +20,23 @@
"devDependencies": { "devDependencies": {
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.8",
"@types/node": "^22", "@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.1",
"svelte": "^5.45.6", "svelte": "^5.48.5",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.5",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.5.0",
"fast-xml-parser": "^5.3.3", "fast-xml-parser": "^5.3.3",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
} }

View File

@@ -2,18 +2,15 @@ import Database from 'bun:sqlite';
// Database path - adjust if your database is located elsewhere // Database path - adjust if your database is located elsewhere
const dbPath = process.env.DATABASE_URL || './local.db'; const dbPath = process.env.DATABASE_URL || './local.db';
console.log(`Connecting to database: ${dbPath}`); console.log(`Connecting to database: ${dbPath}`);
const db = new Database(dbPath); const db = new Database(dbPath);
// Query all rows from daily_completions // Query all rows from daily_completions
const query = db.query(` const query = db.query(`
SELECT date, guess_count SELECT date, guess_count
FROM daily_completions FROM daily_completions
ORDER BY date ORDER BY date
`); `);
const rows = query.all() as { date: string; guess_count: number }[]; const rows = query.all() as { date: string; guess_count: number }[];
if (rows.length === 0) { if (rows.length === 0) {
@@ -50,4 +47,60 @@ const overallAvg = (totalGuesses / totalCompletions).toFixed(2);
console.log('--------------|-------------|-------------------'); console.log('--------------|-------------|-------------------');
console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`); console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`);
db.close(); // Calculate correlation between avg_guesses and completions
function calculateCorrelation(data: { avgGuesses: number; completions: number }[]): number {
const n = data.length;
if (n < 2) return 0;
const avgX = data.reduce((sum, d) => sum + d.avgGuesses, 0) / n;
const avgY = data.reduce((sum, d) => sum + d.completions, 0) / n;
let numerator = 0;
let sumXSquared = 0;
let sumYSquared = 0;
for (const d of data) {
const xDiff = d.avgGuesses - avgX;
const yDiff = d.completions - avgY;
numerator += xDiff * yDiff;
sumXSquared += xDiff * xDiff;
sumYSquared += yDiff * yDiff;
}
const denominator = Math.sqrt(sumXSquared * sumYSquared);
return denominator === 0 ? 0 : numerator / denominator;
}
// Prepare data for correlation analysis
const allData = Array.from(dateStats.entries()).map(([date, stats]) => ({
date,
avgGuesses: stats.total / stats.count,
completions: stats.count
}));
// Split into pre and post marketing periods
const marketingStartDate = '2026-01-08';
const preMarketing = allData.filter(d => d.date < marketingStartDate);
const postMarketing = allData.filter(d => d.date >= marketingStartDate);
console.log('\n=== Correlation Analysis ===\n');
const allCorrelation = calculateCorrelation(allData);
console.log(`Overall correlation (avg_guesses vs completions): ${allCorrelation.toFixed(3)}`);
if (preMarketing.length >= 2) {
const preCorrelation = calculateCorrelation(preMarketing);
console.log(`Pre-marketing correlation (before ${marketingStartDate}): ${preCorrelation.toFixed(3)} (n=${preMarketing.length} days)`);
}
if (postMarketing.length >= 2) {
const postCorrelation = calculateCorrelation(postMarketing);
console.log(`Post-marketing correlation (${marketingStartDate} onward): ${postCorrelation.toFixed(3)} (n=${postMarketing.length} days)`);
}
console.log('\nInterpretation:');
console.log(' r close to -1: Strong negative correlation (easier verses → more completions)');
console.log(' r close to 0: No correlation');
console.log(' r close to +1: Strong positive correlation (harder verses → more completions)');
db.close();

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="600" height="530" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="#1185fe"/>
</svg>

After

Width:  |  Height:  |  Size: 745 B

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,217 @@
<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 on:keydown={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={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

@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
variant?: "primary" | "google" | "apple" | "secondary" | "danger";
onclick?: () => void;
class?: string;
type?: "button" | "submit" | "reset";
}
let {
children,
variant = "primary",
onclick,
class: className = "",
type = "button",
}: Props = $props();
const variantClasses = {
primary:
"bg-blue-500 hover:bg-blue-600 text-white border-gray-500 shadow-md hover:shadow-lg",
google: "bg-white hover:bg-gray-50 text-gray-700 border-gray-500 shadow-md hover:shadow-lg",
apple: "bg-black hover:bg-gray-900 text-white border-gray-500 shadow-md hover:shadow-lg",
secondary:
"bg-white/50 hover:bg-white/70 text-gray-700 border-gray-500 shadow-sm hover:shadow-md backdrop-blur-sm",
danger: "bg-red-500 hover:bg-red-600 text-white border-gray-500 shadow-md hover:shadow-lg",
};
</script>
<button
{type}
{onclick}
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
variant
]} {className}"
>
{@render children()}
</button>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { browser } from "$app/environment";
import Container from "./Container.svelte";
interface Props {
reference: string;
bookId: string;
onCompleted?: () => void;
}
let { reference, bookId, onCompleted }: Props = $props();
// Parse the chapter from the reference (e.g., "John 3:16" -> 3)
function parseChapterFromReference(ref: string): number {
const match = ref.match(/\s(\d+):/);
return match ? parseInt(match[1], 10) : 1;
}
// Get the number of chapters for a book
function getChapterCount(bookId: string): number {
const chapterCounts: Record<string, number> = {
GEN: 50,
EXO: 40,
LEV: 27,
NUM: 36,
DEU: 34,
JOS: 24,
JDG: 21,
RUT: 4,
"1SA": 31,
"2SA": 24,
"1KI": 22,
"2KI": 25,
"1CH": 29,
"2CH": 36,
EZR: 10,
NEH: 13,
EST: 10,
JOB: 42,
PSA: 150,
PRO: 31,
ECC: 12,
SNG: 8,
ISA: 66,
JER: 52,
LAM: 5,
EZK: 48,
DAN: 12,
HOS: 14,
JOL: 3,
AMO: 9,
OBA: 1,
JON: 4,
MIC: 7,
NAM: 3,
HAB: 3,
ZEP: 3,
HAG: 2,
ZEC: 14,
MAL: 4,
MAT: 28,
MRK: 16,
LUK: 24,
JHN: 21,
ACT: 28,
ROM: 16,
"1CO": 16,
"2CO": 13,
GAL: 6,
EPH: 6,
PHP: 4,
COL: 4,
"1TH": 5,
"2TH": 3,
"1TI": 6,
"2TI": 4,
TIT: 3,
PHM: 1,
HEB: 13,
JAS: 5,
"1PE": 5,
"2PE": 3,
"1JN": 5,
"2JN": 1,
"3JN": 1,
JUD: 1,
REV: 22,
};
return chapterCounts[bookId] || 1;
}
// Generate 6 random chapter options including the correct one
function generateChapterOptions(
correctChapter: number,
totalChapters: number,
): number[] {
const options = new Set<number>();
options.add(correctChapter);
if (totalChapters >= 6) {
while (options.size < 6) {
const randomChapter =
Math.floor(Math.random() * totalChapters) + 1;
options.add(randomChapter);
}
} else {
while (options.size < 6) {
const randomChapter = Math.floor(Math.random() * 10) + 1;
options.add(randomChapter);
}
}
return Array.from(options).sort(() => Math.random() - 0.5);
}
let correctChapter = $derived(parseChapterFromReference(reference));
let totalChapters = $derived(getChapterCount(bookId));
let chapterOptions = $state<number[]>([]);
$effect(() => {
if (chapterOptions.length === 0) {
chapterOptions = generateChapterOptions(
correctChapter,
totalChapters,
);
}
});
let selectedChapter = $state<number | null>(null);
let hasAnswered = $state(false);
// Load saved state from localStorage
$effect(() => {
if (!browser) return;
const key = `bibdle-chapter-guess-${reference}`;
const saved = localStorage.getItem(key);
if (saved) {
const data = JSON.parse(saved);
selectedChapter = data.selectedChapter;
hasAnswered = data.hasAnswered;
chapterOptions = data.chapterOptions ?? [];
}
});
// Save state to localStorage whenever options are generated or answer given
$effect(() => {
if (!browser || chapterOptions.length === 0) return;
const key = `bibdle-chapter-guess-${reference}`;
localStorage.setItem(
key,
JSON.stringify({ selectedChapter, hasAnswered, chapterOptions }),
);
});
function handleChapterSelect(chapter: number) {
if (hasAnswered) return;
selectedChapter = chapter;
hasAnswered = true;
if (onCompleted) {
onCompleted();
}
}
let isCorrect = $derived(
selectedChapter !== null && selectedChapter === correctChapter,
);
</script>
<Container
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
>
<div class="text-center">
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
<p class="text-sm sm:text-base opacity-80 mb-6">
Guess the chapter for an even higher grade
</p>
<div
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
>
{#each chapterOptions as chapter}
<button
onclick={() => handleChapterSelect(chapter)}
disabled={hasAnswered}
class={`
w-20 h-20 sm:w-24 sm:h-24 text-2xl sm:text-3xl font-bold rounded-xl
transition-all duration-300 border-2
${
hasAnswered
? chapter === correctChapter
? "bg-green-500 text-white border-green-600 shadow-lg"
: selectedChapter === chapter
? isCorrect
? "bg-green-500 text-white border-green-600 shadow-lg"
: "bg-red-400 text-white border-red-500"
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
}
`}
>
{chapter}
</button>
{/each}
</div>
{#if hasAnswered}
<p
class="text-xl sm:text-2xl font-bold mb-2"
class:text-green-600={isCorrect}
class:text-red-600={!isCorrect}
>
{isCorrect ? "✓ Correct!" : "✗ Incorrect"}
</p>
<p class="text-sm opacity-80">
The verse is from chapter {correctChapter}
</p>
{#if isCorrect}
<p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p>
{/if}
{/if}
</div>
</Container>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className = "" }: Props = $props();
</script>
<div
class="inline-flex flex-col items-center bg-white/10 backdrop-blur-sm rounded-2xl border border-white/20 shadow-sm {className}"
>
{@render children()}
</div>

View File

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

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { fade } from "svelte/transition";
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
</script>
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
<div
class="flex flex-col items-center gap-2 bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
>
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
A project by George Powell & Silent Summit Co.
</p>
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
For questions, comments, job opportunities, or cash donations,
please email <a
class="text-blue-400"
href="mailto:george+bibdle@silentsummit.co"
>george@silentsummit.co</a
>
</p> -->
<!-- <p class="text-lg font-triodion font-black text-gray-800 tabular-nums">
</p> -->
<!-- Bluesky Social Media Button -->
</div>
<div class="mt-8 flex items-center justify-center gap-6">
<a
href="https://bsky.app/profile/snail.city"
target="_blank"
rel="noopener noreferrer"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Bluesky"
data-umami-event="Bluesky clicked"
>
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
</a>
<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"
>
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
</a>
<div class="w-0.5 h-8 bg-gray-400"></div>
<a
href="mailto:george+bibdle@silentsummit.co"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email"
data-umami-event="Email clicked"
>
<svg
class="w-8 h-8 text-gray-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
></path>
</svg>
</a>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { browser } from "$app/environment";
import Button from "$lib/components/Button.svelte";
function clearLocalStorage() {
if (!browser) return;
// Clear all bibdle-related localStorage items
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("bibdle-")) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Reload the page to reset state
window.location.reload();
}
</script>
<div class="pt-24 pb-4">
<div class="border-t-2 border-gray-400"></div>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
<Button
variant="secondary"
onclick={() => alert("About page coming soon!")}
class="w-full md:w-auto py-4 md:py-2"
>
About Bibdle / FAQs
</Button>
<Button
variant="google"
onclick={() => alert("Google sign-in coming soon!")}
class="w-full md:w-auto py-4 md:py-2"
>
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
<Button
variant="apple"
onclick={() => alert("Apple sign-in coming soon!")}
class="w-full md:w-auto py-4 md:py-2"
>
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
/>
</svg>
Sign in with Apple
</Button>
</div>
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
<Button
variant="primary"
onclick={() => alert("Patreon coming soon!")}
class="w-full md:w-auto py-4 md:py-2"
>
Become a Patron
</Button>
</div>
<Button
variant="danger"
onclick={clearLocalStorage}
class="w-full py-4 md:py-2"
>
Clear LocalStorage
</Button>
</div>

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
</script>
<!-- <div
class="my-12 p-4 bg-linear-to-r from-blue-50 to-indigo-50 rounded-2xl shadow-md text-center text-sm md:text-base text-gray-600"
in:fade={{ delay: 1500, duration: 1000 }}
>
Thank you so much for playing! Feel free to email me directly with feedback:
<a
href="mailto:george@snail.city"
class="font-semibold text-blue-600 hover:text-blue-800 underline"
>george@snail.city</a
>
</div> -->
<div class="text-center py-12">
<div
class="inline-flex w-full flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
>
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
A project by George Powell & Silent Summit Co.
</p>
<!-- <p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
</p> -->
</div>
</div>

View File

@@ -1,105 +1,217 @@
<script lang="ts"> <script lang="ts">
interface Guess { import { bibleBooks } from "$lib/types/bible";
book: { import Container from "./Container.svelte";
id: string;
name: string;
testament: string;
section: string;
};
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
}
let { guesses, correctBookId }: { guesses: Guess[]; correctBookId: string } = interface Guess {
$props(); book: {
id: string;
name: string;
testament: string;
section: string;
};
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
firstLetterMatch: boolean;
}
let hasGuesses = $derived(guesses.length > 0); let {
guesses,
correctBookId,
}: { guesses: Guess[]; correctBookId: string } = $props();
let hasGuesses = $derived(guesses.length > 0);
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
if (isCorrect) return "bg-green-500 border-green-600";
if (isAdjacent) return "bg-yellow-500 border-yellow-600";
return "bg-red-500 border-red-600";
}
function getFirstLetter(bookName: string): string {
const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
function getBoxContent(
guess: Guess,
column: "book" | "firstLetter" | "testament" | "section",
): string {
switch (column) {
case "book":
return guess.book.name;
case "firstLetter":
// Check if this is the special Epistles + "1" case
const correctBook = bibleBooks.find(
(b) => b.id === correctBookId,
);
const correctIsEpistlesWithNumber =
(correctBook?.section === "Pauline Epistles" ||
correctBook?.section === "General Epistles") &&
correctBook.name[0] === "1";
const guessIsEpistlesWithNumber =
(guess.book.section === "Pauline Epistles" ||
guess.book.section === "General Epistles") &&
guess.book.name[0] === "1";
if (
correctIsEpistlesWithNumber &&
guessIsEpistlesWithNumber &&
guess.firstLetterMatch
) {
const words = [
"Exactly",
"Right",
"Yes",
"Naturally",
"Of course",
"Sure",
];
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
}
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
case "testament":
return (
guess.book.testament.charAt(0).toUpperCase() +
guess.book.testament.slice(1).toLowerCase()
);
case "section":
return guess.book.section;
}
}
</script> </script>
{#if hasGuesses} {#if !hasGuesses}
<div class="bg-white rounded-2xl shadow-xl overflow-x-auto fade-in"> <Container class="p-6 text-center">
<table class="w-full"> <h2 class="font-triodion text-xl italic mb-3 text-gray-800">
<thead class="fade-in"> Instructions
<tr class="bg-linear-to-r from-gray-50 to-gray-300"> </h2>
<th <p class="text-gray-700 leading-relaxed italic">
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200" Guess what book of the bible you think the verse is from. You will
>Book</th get clues to tell you if your guess is close or not. Green means the
> category is correct; red means wrong.
<th </p>
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200" </Container>
>Testament</th {:else}
> <div class="space-y-3">
<th <!-- Column Headers -->
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200" <div
>Section</th class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
> >
</tr> <div
</thead> class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
<tbody> >
{#each guesses as guess, index (guess.book.id)} Testament
<tr </div>
class="border-b border-gray-100 transition-colors {guess.book.id === <div
correctBookId class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
? 'bg-green-200 animate-shine' >
: 'hover:bg-gray-50'} {index === 0 ? 'fade-in' : ''}" Section
> </div>
<td <div
class="p-3 sm:p-4 md:p-6 text-sm sm:text-base font-bold md:text-lg" class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
> >
{guess.book.id === correctBookId ? "✅" : "❌"} First Letter
{guess.book.name} </div>
</td> <div
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg"> class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
{guess.testamentMatch ? "✅" : "❌"} >
{guess.book.testament.charAt(0).toUpperCase() + Book
guess.book.testament.slice(1).toLowerCase()} </div>
</td> </div>
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg">
{guess.sectionMatch ? "✅" : "❌"} {#each guesses as guess, rowIndex (guess.book.id)}
{guess.adjacent ? "‼️ " : ""}{guess.book.section} <div class="flex gap-2 justify-center">
</td> <!-- Testament Column -->
</tr> <div
{/each} 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(
</tbody> guess.testamentMatch,
</table> )}"
</div> style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "testament")}</span
>
</div>
<!-- Section Column -->
<div
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.sectionMatch,
guess.adjacent,
)}"
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "section")}
{#if guess.adjacent}
‼️
{/if}
</span>
</div>
<!-- First Letter Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.firstLetterMatch,
)}"
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "firstLetter")}</span
>
</div>
<!-- Book Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
guess.book.id === correctBookId,
)}"
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span
>
</div>
</div>
{/each}
</div>
<!-- </div> -->
{/if} {/if}
<style> <style>
@keyframes shine { @keyframes flipIn {
0% { 0% {
background-position: -200% 0; opacity: 0;
} transform: rotateX(-90deg);
100% { }
background-position: 200% 0; 50% {
} transform: rotateX(0deg);
} }
100% {
opacity: 1;
transform: rotateX(0deg);
}
}
.animate-shine { .animate-flip-in {
background: linear-gradient(110deg, #dcffe7 45%, #f1fff5 50%, #dcffe7 55%); opacity: 0;
background-size: 200% 100%; transform: rotateX(-90deg);
animation: shine 5s infinite; animation: flipIn 0.6s ease-out forwards;
} }
.animate-shine.fade-in { @keyframes fadeIn {
animation: from {
fadeIn 0.5s ease-out, opacity: 0;
shine 5s infinite; transform: translateY(-10px);
} }
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn { .fade-in {
from { animation: fadeIn 0.5s ease-out;
opacity: 0; }
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
</style> </style>

View File

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

View File

@@ -1,55 +1,92 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible"; import { bibleBooks, type BibleBook } from "$lib/types/bible";
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props(); let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
let filteredBooks = $derived( let filteredBooks = $derived(
bibleBooks.filter((book) => bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()), book.name.toLowerCase().includes(searchQuery.toLowerCase())
), )
); );
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && filteredBooks.length > 0) { if (e.key === "Enter" && filteredBooks.length > 0) {
submitGuess(filteredBooks[0].id); submitGuess(filteredBooks[0].id);
} }
} }
</script> </script>
<div class="mb-12"> <div class="relative">
<input <div class="relative">
bind:value={searchQuery} <svg
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..." class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
class="w-full p-4 sm:p-6 border-2 border-gray-200 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all shadow-lg" fill="none"
onkeydown={handleKeydown} stroke="currentColor"
/> viewBox="0 0 24 24"
{#if searchQuery && filteredBooks.length > 0} xmlns="http://www.w3.org/2000/svg"
<ul >
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-2xl shadow-lg" <path
> stroke-linecap="round"
{#each filteredBooks as book (book.id)} stroke-linejoin="round"
<li> stroke-width="2"
<button d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
class="w-full p-4 sm:p-5 text-left {guessedIds.has( />
book.id, </svg>
) <input
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600' bind:value={searchQuery}
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center" placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
onclick={() => submitGuess(book.id)} class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
> onkeydown={handleKeydown}
<span autocomplete="off"
class="font-semibold {guessedIds.has(book.id) />
? 'line-through text-gray-500' {#if searchQuery}
: ''}">{book.name}</span <button
> class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
<span class="ml-auto text-sm opacity-75" onclick={() => (searchQuery = "")}
>({book.testament.toUpperCase()})</span aria-label="Clear search"
> >
</button> <svg
</li> class="w-5 h-5 sm:w-6 sm:h-6"
{/each} fill="none"
</ul> stroke="currentColor"
{:else if searchQuery} viewBox="0 0 24 24"
<p class="mt-4 text-center text-gray-500 p-8">No books found</p> xmlns="http://www.w3.org/2000/svg"
{/if} >
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{#if searchQuery && filteredBooks.length > 0}
<ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
>
{#each filteredBooks as book (book.id)}
<li>
<button
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
onclick={() => submitGuess(book.id)}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-500'
: ''}">{book.name}</span
>
<span class="ml-auto text-sm opacity-75"
>({book.testament.toUpperCase()})</span
>
</button>
</li>
{/each}
</ul>
{:else if searchQuery}
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
{/if}
</div> </div>

View File

@@ -1,25 +1,38 @@
<script lang="ts"> <script lang="ts">
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";
let { data, isWon }: { data: PageData; isWon: boolean } = $props(); let {
data,
isWon,
blurChapter = false,
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse); let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived( let displayReference = $derived(
dailyVerse.reference.replace(/^Psalms /, "Psalm ") blurChapter
? dailyVerse.reference
.replace(/^Psalms /, "Psalm ")
.replace(/\s(\d+):/, " ?:")
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
); );
let displayVerseText = $derived( let displayVerseText = $derived(
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase()) dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase())
.replace(/[,:;-—]$/, "...")
); );
</script> </script>
<div class="bg-gray-50 rounded-2xl shadow-xl p-8 sm:p-12 mb-4 sm:mb-12 w-full"> <Container class="w-full p-8 sm:p-12 bg-white/70">
<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} {#if isWon}
<p class="text-center text-lg! big-text text-green-600! font-bold mt-8"> <p
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
>
{displayReference} {displayReference}
</p> </p>
{/if} {/if}
</div> </Container>

View File

@@ -1,196 +1,245 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { getBookById, toOrdinal, getNextGradeMessage } from "$lib/utils/game"; import {
import { onMount } from "svelte"; getBookById,
toOrdinal,
getNextGradeMessage,
} from "$lib/utils/game";
import { onMount } from "svelte";
import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte";
import ChapterGuess from "./ChapterGuess.svelte";
interface StatsData { interface StatsData {
solveRank: number; solveRank: number;
guessRank: number; guessRank: number;
totalSolves: number; totalSolves: number;
averageGuesses: number; averageGuesses: number;
} tiedCount: number;
percentile: number;
}
interface WeightedMessage { interface WeightedMessage {
text: string; text: string;
weight: number; weight: number;
} }
let { let {
grade, grade,
statsData, statsData,
correctBookId, correctBookId,
handleShare, handleShare,
copyToClipboard, copyToClipboard,
copied = $bindable(false), copied = $bindable(false),
statsSubmitted, statsSubmitted,
guessCount, guessCount,
} = $props(); reference,
onChapterGuessCompleted,
} = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? ""); let bookName = $derived(getBookById(correctBookId)?.name ?? "");
let hasWebShare = $derived( let hasWebShare = $derived(
typeof navigator !== "undefined" && "share" in navigator typeof navigator !== "undefined" && "share" in navigator,
); );
let copySuccess = $state(false); let copySuccess = $state(false);
// List of congratulations messages with weights // List of congratulations messages with weights
const congratulationsMessages: WeightedMessage[] = [ const congratulationsMessages: WeightedMessage[] = [
{ text: "Congratulations!", weight: 10 }, { text: "Congratulations!", weight: 10 },
{ text: "You got it!", weight: 1000 }, { text: "You got it!", weight: 1000 },
{ text: "Yup,", weight: 100 }, { text: "Yup.", weight: 100 },
{ text: "Very nice!", weight: 1 }, { text: "Very nice!", weight: 1 },
]; ];
// Function to select a random message based on weights // Function to select a random message based on weights
function getRandomCongratulationsMessage(): string { function getRandomCongratulationsMessage(): string {
// Special case for first try success // Special case for first try success
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! 🗣️";
} }
} }
const totalWeight = congratulationsMessages.reduce( const totalWeight = congratulationsMessages.reduce(
(sum, msg) => sum + msg.weight, (sum, msg) => sum + msg.weight,
0 0,
); );
let random = Math.random() * totalWeight; let random = Math.random() * totalWeight;
for (const message of congratulationsMessages) { for (const message of congratulationsMessages) {
random -= message.weight; random -= message.weight;
if (random <= 0) { if (random <= 0) {
return message.text; return message.text;
} }
} }
// Fallback to first message if something goes wrong // Fallback to first message if something goes wrong
return congratulationsMessages[0].text; return congratulationsMessages[0].text;
} }
// Generate the congratulations message // Generate the congratulations message
let congratulationsMessage = $derived(getRandomCongratulationsMessage()); let congratulationsMessage = $derived(getRandomCongratulationsMessage());
</script> </script>
<div <div class="flex flex-col gap-6">
class="p-8 sm:p-12 w-full bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center fade-in" <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"
<!-- <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg"> >
{congratulationsMessage} <p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
</h2> --> {congratulationsMessage} The verse is from
<p class="text-xl sm:text-3xl md:text-4xl"> <span class="font-black text-3xl md:text-4xl">{bookName}</span>.
{congratulationsMessage} The verse is from </p>
<span class="font-black text-xl sm:text-2xl md:text-3xl">{bookName}</span>. <p class="text-lg sm:text-xl md:text-2xl mt-4">
</p> You guessed correctly after {guessCount}
<p {guessCount === 1 ? "guess" : "guesses"}.
class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block" <span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
> >{grade}</span
Your grade: {grade} >
</p> </p>
{#if hasWebShare} <div class="flex justify-center mt-6">
<button {#if hasWebShare}
onclick={handleShare} <!-- mobile and arc in production -->
data-umami-event="Share" <button
class="mt-4 text-2xl font-bold p-2 bg-white/20 hover:bg-white/30 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none" onclick={handleShare}
> data-umami-event="Share"
📤 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"
</button> >
<button 📤 Share
onclick={() => { </button>
copyToClipboard(); <button
copySuccess = true; onclick={() => {
setTimeout(() => { copyToClipboard();
copySuccess = false; copySuccess = true;
}, 3000); setTimeout(() => {
}} copySuccess = false;
data-umami-event="Copy to Clipboard" }, 3000);
class={`mt-4 text-2xl font-bold p-2 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${ }}
copySuccess data-umami-event="Copy to Clipboard"
? "bg-green-400/50 hover:bg-green-500/60" class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
: "bg-white/20 hover:bg-white/30" copySuccess
}`} ? "bg-white/30"
> : "bg-white/70 hover:bg-white/80"
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"} }`}
</button> >
{:else} {copySuccess ? "✅ Copied!" : "📋 Copy"}
<button </button>
onclick={handleShare} {:else}
data-umami-event="Share" <!-- dev mode and desktop browsers -->
class={`mt-4 text-2xl font-bold p-2 ${ <button
copied onclick={handleShare}
? "bg-green-400/50 hover:bg-green-500/60" data-umami-event="Copy to Clipboard"
: "bg-white/20 hover:bg-white/30" class={`text-2xl font-bold p-4 ${
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`} 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 to clipboard!" : "📤 Share"} >
</button> {copied ? "✅ Copied!" : "📋 Share"}
{/if} </button>
{/if}
</div>
<p class="pt-6 big-text text-gray-100!"> {#if guessCount !== 1}
{getNextGradeMessage(guessCount)} <p class="pt-6 big-text text-gray-700!">
</p> {getNextGradeMessage(guessCount)}
</p>
{/if}
</Container>
<!-- Statistics Display --> <!-- S++ Bonus Challenge for first try -->
{#if statsData} {#if guessCount === 1}
<div class="mt-6" in:fade={{ delay: 800 }}> <ChapterGuess
<div class="grid grid-cols-3 gap-4 gap-x-8 text-center"> {reference}
<!-- Solve Rank Column --> bookId={correctBookId}
<div class="flex flex-col"> onCompleted={onChapterGuessCompleted}
<div class="text-3xl sm:text-4xl font-black"> />
#{statsData.solveRank} {/if}
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
You were the {toOrdinal(statsData.solveRank)} person to solve today
</div>
</div>
<!-- Guess Rank Column --> <CountdownTimer />
<div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black">
{Math.round(
((statsData.totalSolves - statsData.guessRank + 1) /
statsData.totalSolves) *
100
)}%
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
total solves
</div>
</div>
<!-- Average Column --> <!-- Statistics Display -->
<div class="flex flex-col"> {#if statsData}
<div class="text-3xl sm:text-4xl font-black"> <Container
{statsData.averageGuesses} class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
</div> >
<div class="text-xs sm:text-sm opacity-90 mt-1"> <div
People guessed correctly after {statsData.averageGuesses} class="grid grid-cols-3 gap-4 gap-x-8 text-center"
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on average in:fade={{ delay: 800 }}
</div> >
</div> <!-- Solve Rank Column -->
</div> <div class="flex flex-col">
</div> <div
{:else if !statsSubmitted} class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
<div class="mt-6 text-sm opacity-80">Submitting stats...</div> >
{/if} #{statsData.solveRank}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
You were the {toOrdinal(statsData.solveRank)} person to solve
today
</div>
</div>
<!-- Guess Rank Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
{toOrdinal(statsData.guessRank)}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
{statsData.totalSolves === 1
? "solve"
: "solves"}{statsData.tiedCount > 0
? `, tied with ${statsData.tiedCount} ${statsData.tiedCount === 1 ? "other" : "others"}`
: ""}.<br />
{#if statsData.percentile <= 25}
<span class="font-bold">
(Top {statsData.percentile}%)
</span>
{/if}
</div>
</div>
<!-- Average Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
{statsData.averageGuesses}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
People solved after {statsData.averageGuesses}
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on
average
</div>
</div>
</div>
</Container>
{:else if !statsSubmitted}
<Container
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
>
<div class="text-sm opacity-80">Submitting stats...</div>
</Container>
{/if}
</div> </div>
<style> <style>
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(-10px); transform: translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
.fade-in { .fade-in {
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }
</style> </style>

115
src/lib/server/auth.test.ts Normal file
View File

@@ -0,0 +1,115 @@
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,
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 },
session: table.session session: table.session
}) })
.from(table.session) .from(table.session)
@@ -79,3 +79,37 @@ 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,
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

@@ -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,14 @@
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core'; import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm'; 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'),
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(),
@@ -32,7 +38,7 @@ export const dailyCompletions = sqliteTable('daily_completions', {
guessCount: integer('guess_count').notNull(), guessCount: integer('guess_count').notNull(),
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(), completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({ }, (table) => ({
uniqueCompletion: unique().on(table.anonymousId, table.date), anonymousIdDateIndex: index('anonymous_id_date_idx').on(table.anonymousId, table.date),
dateIndex: index('date_idx').on(table.date), dateIndex: index('date_idx').on(table.date),
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount), dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
})); }));

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

@@ -353,6 +353,54 @@ export function getRandomGreekVerses(count: number = 3): {
return null; return null;
} }
/**
* Get a random set of verses from a specific book
* Returns `count` consecutive verses by default
*/
export function getRandomVersesFromBook(
bookNumber: number,
count: number = 1
): {
bookId: string;
bookName: string;
chapter: number;
startVerse: number;
endVerse: number;
verses: string[];
} | null {
const book = getBookByNumber(bookNumber);
if (!book) {
return null;
}
// Try up to 10 times to find a valid passage
for (let attempt = 0; attempt < 10; attempt++) {
const chapterNumber = getRandomChapterNumber(bookNumber);
const verseCount = getVerseCount(bookNumber, chapterNumber);
// Skip chapters that don't have enough verses
if (verseCount < count) {
continue;
}
const startVerse = getRandomStartVerse(bookNumber, chapterNumber, count);
const verses = extractVerses(bookNumber, chapterNumber, startVerse, count);
if (verses.length === count) {
return {
bookId: book.id,
bookName: book.name,
chapter: chapterNumber,
startVerse,
endVerse: startVerse + count - 1,
verses
};
}
}
return null;
}
/** /**
* Format a reference string from verse data * Format a reference string from verse data
*/ */

86
src/lib/utils/stats.ts Normal file
View File

@@ -0,0 +1,86 @@
export interface UserStats {
totalSolves: number;
avgGuesses: number;
gradeDistribution: {
'S++': number;
'S+': number;
'A+': number;
'A': number;
'B+': number;
'B': number;
'C+': number;
'C': number;
};
currentStreak: number;
bestStreak: number;
recentCompletions: Array<{
date: string;
guessCount: number;
grade: string;
}>;
worstDay: {
date: string;
guessCount: number;
} | null;
bestBook: {
bookId: string;
avgGuesses: number;
count: number;
} | null;
mostSeenBook: {
bookId: string;
count: number;
} | null;
totalBooksSeenOT: number;
totalBooksSeenNT: number;
}
export function getGradeColor(grade: string): string {
switch (grade) {
case 'S++': return 'text-purple-600 bg-purple-100';
case 'S+': return 'text-yellow-600 bg-yellow-100';
case 'A+': return 'text-green-600 bg-green-100';
case 'A': return 'text-green-500 bg-green-50';
case 'B+': return 'text-blue-600 bg-blue-100';
case 'B': return 'text-blue-500 bg-blue-50';
case 'C+': return 'text-orange-600 bg-orange-100';
case 'C': return 'text-red-600 bg-red-100';
default: return 'text-gray-600 bg-gray-100';
}
}
export function formatDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
export function getStreakMessage(currentStreak: number): string {
if (currentStreak === 0) {
return "Start your streak today!";
} else if (currentStreak === 1) {
return "Keep it going!";
} else if (currentStreak < 7) {
return `${currentStreak} days strong!`;
} else if (currentStreak < 30) {
return `${currentStreak} day streak - amazing!`;
} else {
return `${currentStreak} days - you're unstoppable!`;
}
}
export function getPerformanceMessage(avgGuesses: number): string {
if (avgGuesses <= 2) {
return "Exceptional performance!";
} else if (avgGuesses <= 4) {
return "Great performance!";
} else if (avgGuesses <= 6) {
return "Good performance!";
} else if (avgGuesses <= 8) {
return "Room for improvement!";
} else {
return "Keep practicing!";
}
}

View File

@@ -1,17 +1,26 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import "./layout.css"; import "./layout.css";
import favicon from "$lib/assets/favicon.ico"; import favicon from "$lib/assets/favicon.ico";
onMount(() => {
if (browser) {
const script = document.createElement('script');
script.defer = true;
script.src = 'https://umami.snail.city/script.js';
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
document.body.appendChild(script);
}
});
let { children } = $props(); let { children } = $props();
</script> </script>
<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

@@ -34,14 +34,16 @@ async function getTodayVerse(): Promise<DailyVerse> {
return inserted; return inserted;
} }
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async ({ locals }) => {
const dailyVerse = await getTodayVerse(); const dailyVerse = await getTodayVerse();
const correctBook = getBookById(dailyVerse.bookId) ?? null; const correctBook = getBookById(dailyVerse.bookId) ?? null;
return { return {
dailyVerse, dailyVerse,
correctBookId: dailyVerse.bookId, correctBookId: dailyVerse.bookId,
correctBook correctBook,
user: locals.user,
session: locals.session
}; };
}; };
@@ -91,13 +93,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,442 +1,559 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible"; 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 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";
import GuessesTable from "$lib/components/GuessesTable.svelte"; import GuessesTable from "$lib/components/GuessesTable.svelte";
import CountdownTimer from "$lib/components/CountdownTimer.svelte"; import WinScreen from "$lib/components/WinScreen.svelte";
import WinScreen from "$lib/components/WinScreen.svelte"; import Credits from "$lib/components/Credits.svelte";
import Feedback from "$lib/components/Feedback.svelte"; import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte"; import DevButtons from "$lib/components/DevButtons.svelte";
import { getGrade } from "$lib/utils/game"; import AuthModal from "$lib/components/AuthModal.svelte";
import { getGrade } from "$lib/utils/game";
import { enhance } from '$app/forms';
interface Guess { interface Guess {
book: BibleBook; book: BibleBook;
testamentMatch: boolean; testamentMatch: boolean;
sectionMatch: boolean; sectionMatch: boolean;
adjacent: boolean; adjacent: boolean;
} firstLetterMatch: boolean;
}
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 user = $derived(data.user);
let session = $derived(data.session);
let guesses = $state<Guess[]>([]); let guesses = $state<Guess[]>([]);
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 chapterCorrect = $state(false);
let anonymousId = $state(""); let anonymousId = $state("");
let statsSubmitted = $state(false); let statsSubmitted = $state(false);
let statsData = $state<{ let authModalOpen = $state(false);
solveRank: number; let statsData = $state<{
guessRank: number; solveRank: number;
totalSolves: number; guessRank: number;
averageGuesses: number; totalSolves: number;
} | null>(null); averageGuesses: number;
tiedCount: number;
percentile: number;
} | null>(null);
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id))); let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
const currentDate = $derived( const currentDate = $derived(
new Date().toLocaleDateString("en-US", { new Date().toLocaleDateString("en-US", {
weekday: "long", weekday: "long",
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}) }),
); );
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId)); let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
let grade = $derived( let grade = $derived(
isWon isWon
? getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0) ? guesses.length === 1 && chapterCorrect
: "" ? "S++"
); : getGrade(
guesses.length,
getBookById(correctBookId)?.popularity ?? 0,
)
: "",
);
let blurChapter = $derived(
isWon && guesses.length === 1 && !chapterGuessCompleted,
);
function getBookById(id: string): BibleBook | undefined { function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id); return bibleBooks.find((b) => b.id === id);
} }
function isAdjacent(id1: string, id2: string): boolean { function isAdjacent(id1: string, id2: string): boolean {
const b1 = getBookById(id1); const b1 = getBookById(id1);
const b2 = getBookById(id2); const b2 = getBookById(id2);
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1); return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
} }
function submitGuess(bookId: string) { function getFirstLetter(bookName: string): string {
if (guesses.some((g) => g.book.id === bookId)) return; const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
const book = getBookById(bookId); function submitGuess(bookId: string) {
if (!book) return; if (guesses.some((g) => g.book.id === bookId)) return;
const correctBook = getBookById(correctBookId); const book = getBookById(bookId);
if (!correctBook) return; if (!book) return;
const testamentMatch = book.testament === correctBook.testament; const correctBook = getBookById(correctBookId);
const sectionMatch = book.section === correctBook.section; if (!correctBook) return;
const adjacent = isAdjacent(book.id, correctBookId);
console.log( const testamentMatch = book.testament === correctBook.testament;
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}` const sectionMatch = book.section === correctBook.section;
); const adjacent = isAdjacent(bookId, correctBookId);
if (guesses.length === 0) { // Special case: if correct book is in the Epistles + starts with "1",
const key = `bibdle-first-guess-${dailyVerse.date}`; // any guess starting with "1" counts as first letter match
if ( const correctIsEpistlesWithNumber =
localStorage.getItem(key) !== "true" && (correctBook.section === "Pauline Epistles" ||
browser && correctBook.section === "General Epistles") &&
(window as any).umami correctBook.name[0] === "1";
) { const guessIsEpistlesWithNumber =
(window as any).umami.track("First guess"); (book.section === "Pauline Epistles" ||
localStorage.setItem(key, "true"); book.section === "General Epistles") &&
} book.name[0] === "1";
}
guesses = [ const firstLetterMatch =
{ correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
book, ? true
testamentMatch, : getFirstLetter(book.name).toUpperCase() ===
sectionMatch, getFirstLetter(correctBook.name).toUpperCase();
adjacent,
},
...guesses,
];
searchQuery = ""; console.log(
} `Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
);
function generateUUID(): string { if (guesses.length === 0) {
// Try native randomUUID if available const key = `bibdle-first-guess-${dailyVerse.date}`;
if (typeof window.crypto.randomUUID === "function") { if (
return window.crypto.randomUUID(); localStorage.getItem(key) !== "true" &&
} browser &&
(window as any).umami
) {
(window as any).umami.track("First guess");
localStorage.setItem(key, "true");
}
}
// Fallback UUID v4 generator for older browsers guesses = [
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { {
const r = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0; book,
const v = c === "x" ? r : (r & 0x3) | 0x8; testamentMatch,
return v.toString(16); sectionMatch,
}); adjacent,
} firstLetterMatch,
},
...guesses,
];
function getOrCreateAnonymousId(): string { searchQuery = "";
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 function generateUUID(): string {
$effect(() => { // Try native randomUUID if available
if (!browser) return; if (typeof window.crypto.randomUUID === "function") {
anonymousId = getOrCreateAnonymousId(); return window.crypto.randomUUID();
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`; }
statsSubmitted = localStorage.getItem(statsKey) === "true";
});
$effect(() => { // Fallback UUID v4 generator for older browsers
if (!browser) return; return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
isDev = window.location.host === "localhost:5173"; const r =
}); window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// Load saved guesses function getOrCreateAnonymousId(): string {
$effect(() => { if (!browser) return "";
if (!browser) return; const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
const key = `bibdle-guesses-${dailyVerse.date}`; // Initialize anonymous ID
const saved = localStorage.getItem(key); $effect(() => {
if (saved) { if (!browser) return;
let savedIds: string[] = JSON.parse(saved); anonymousId = getOrCreateAnonymousId();
savedIds = Array.from(new Set(savedIds)); if ((window as any).umami) {
guesses = savedIds.map((bookId: string) => { // Use user id if logged in, otherwise use anonymous id
const book = getBookById(bookId)!; (window as any).umami.identify(user ? user.id : anonymousId);
const correctBook = getBookById(correctBookId)!; }
const testamentMatch = book.testament === correctBook.testament; const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
const sectionMatch = book.section === correctBook.section; statsSubmitted = localStorage.getItem(statsKey) === "true";
const adjacent = isAdjacent(bookId, correctBookId); const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
return { chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
book, if (chapterGuessCompleted) {
testamentMatch, const saved = localStorage.getItem(chapterGuessKey);
sectionMatch, if (saved) {
adjacent, 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(() => { $effect(() => {
if (!browser) return; if (!browser) return;
localStorage.setItem( isDev = window.location.host === "localhost:5173";
`bibdle-guesses-${dailyVerse.date}`, });
JSON.stringify(guesses.map((g) => g.book.id))
);
});
// Auto-submit stats when user wins // Load saved guesses
$effect(() => { $effect(() => {
console.log("Stats effect triggered:", { if (!browser) return;
browser,
isWon,
anonymousId,
statsSubmitted,
statsData,
});
if (!browser || !isWon || !anonymousId) { const key = `bibdle-guesses-${dailyVerse.date}`;
console.log("Basic conditions not met"); const saved = localStorage.getItem(key);
return; 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);
if (statsSubmitted && !statsData) { // Apply same first letter logic as in submitGuess
console.log("Fetching existing stats..."); 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";
(async () => { const firstLetterMatch =
try { correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
const response = await fetch( ? true
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}` : getFirstLetter(book.name).toUpperCase() ===
); getFirstLetter(correctBook.name).toUpperCase();
const result = await response.json();
console.log("Stats response:", result);
if (result.success && result.stats) { return {
console.log("Setting stats data:", result.stats); book,
statsData = result.stats; testamentMatch,
localStorage.setItem( sectionMatch,
`bibdle-stats-submitted-${dailyVerse.date}`, adjacent,
"true" firstLetterMatch,
); };
} 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; $effect(() => {
} if (!browser) return;
localStorage.setItem(
`bibdle-guesses-${dailyVerse.date}`,
JSON.stringify(guesses.map((g) => g.book.id)),
);
});
console.log("Submitting stats..."); // Auto-submit stats when user wins
$effect(() => {
console.log("Stats effect triggered:", {
browser,
isWon,
anonymousId,
statsSubmitted,
statsData,
});
async function submitStats() { if (!browser || !isWon || !anonymousId || statsData) {
try { console.log("Basic conditions not met");
const payload = { return;
anonymousId, }
date: dailyVerse.date,
guessCount: guesses.length,
};
console.log("Sending POST request with:", payload); if (statsSubmitted && !statsData) {
console.log("Fetching existing stats...");
const response = await fetch("/api/submit-completion", { (async () => {
method: "POST", try {
headers: { const response = await fetch(
"Content-Type": "application/json", `/api/submit-completion?anonymousId=${user ? user.id : anonymousId}&date=${dailyVerse.date}`,
}, );
body: JSON.stringify(payload), const result = await response.json();
}); console.log("Stats response:", result);
const result = await response.json(); if (result.success && result.stats) {
console.log("Stats response:", result); 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);
}
})();
if (result.success && result.stats) { return;
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(); console.log("Submitting stats...");
});
$effect(() => { async function submitStats() {
if (!browser || !isWon) return; try {
const key = `bibdle-win-tracked-${dailyVerse.date}`; const payload = {
if (localStorage.getItem(key) === "true") return; anonymousId: user ? user.id : anonymousId,
if ((window as any).umami) { date: dailyVerse.date,
(window as any).umami.track("Guessed correctly", { guessCount: guesses.length,
totalGuesses: guesses.length, };
});
}
localStorage.setItem(key, "true");
});
function generateShareText(): string { console.log("Sending POST request with:", payload);
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", { const response = await fetch("/api/submit-completion", {
month: "short", method: "POST",
day: "numeric", headers: {
year: "numeric", "Content-Type": "application/json",
}); },
const formattedDate = dateFormatter.format( body: JSON.stringify(payload),
new Date(`${dailyVerse.date}T00:00:00`) });
);
const siteUrl = window.location.origin;
return [
`📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} ${guesses.length == 1 ? "guess" : "guesses"})`,
`${emojis}`,
siteUrl,
].join("\n");
}
async function share() { const result = await response.json();
if (!browser) return; console.log("Stats response:", result);
const shareText = generateShareText(); 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);
}
}
try { submitStats();
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() { $effect(() => {
if (!browser) return; if (!browser || !isWon) return;
const key = `bibdle-win-tracked-${dailyVerse.date}`;
if (localStorage.getItem(key) === "true") return;
if ((window as any).umami) {
(window as any).umami.track("Guessed correctly", {
totalGuesses: guesses.length,
});
}
localStorage.setItem(key, "true");
});
const shareText = generateShareText(); function generateShareText(): string {
const emojis = guesses
.slice()
.reverse()
.map((guess) => {
if (guess.book.id === correctBookId) return "✅";
if (guess.adjacent) return "‼️";
if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧";
return "🟥";
})
.join("");
try { const dateFormatter = new Intl.DateTimeFormat("en-US", {
await (navigator as any).clipboard.writeText(shareText); month: "short",
copied = true; day: "numeric",
setTimeout(() => { year: "numeric",
copied = false; });
}, 5000); const formattedDate = dateFormatter.format(
} catch (err) { new Date(`${dailyVerse.date}T00:00:00`),
console.error("Copy to clipboard failed:", err); );
throw err; const siteUrl = window.location.origin;
} return [
} `📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
siteUrl,
].join("\n");
}
function handleShare() { async function share() {
if (copied || !browser) return; if (!browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
share()
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
function clearLocalStorage() { const shareText = generateShareText();
if (!browser) return;
// Clear all bibdle-related localStorage items try {
const keysToRemove: string[] = []; if ("share" in navigator) {
for (let i = 0; i < localStorage.length; i++) { await (navigator as any).share({ text: shareText });
const key = localStorage.key(i); } else {
if (key && key.startsWith("bibdle-")) { await (navigator as any).clipboard.writeText(shareText);
keysToRemove.push(key); }
} } catch (err) {
} console.error("Share failed:", err);
keysToRemove.forEach((key) => localStorage.removeItem(key)); throw err;
// Reload the page to reset state }
window.location.reload(); }
}
async function copyToClipboard() {
if (!browser) return;
const shareText = generateShareText();
try {
await (navigator as any).clipboard.writeText(shareText);
copied = true;
setTimeout(() => {
copied = false;
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
throw err;
}
}
function handleShare() {
if (copied || !browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
share()
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
</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
<!-- <meta
name="description" name="description"
content="Guess which book of the Bible a verse comes from." 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> </div>
<VerseDisplay {data} {isWon} /> <div class="flex flex-col gap-6">
<div class="animate-fade-in-up animate-delay-200">
<VerseDisplay {data} {isWon} {blurChapter} />
</div>
{#if !isWon} {#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}
{statsData} <WinScreen
{correctBookId} {grade}
{handleShare} {statsData}
{copyToClipboard} {correctBookId}
bind:copied {handleShare}
{statsSubmitted} {copyToClipboard}
guessCount={guesses.length} bind:copied
/> {statsSubmitted}
<CountdownTimer /> guessCount={guesses.length}
{/if} reference={dailyVerse.reference}
onChapterGuessCompleted={() => {
chapterGuessCompleted = true;
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
const saved = localStorage.getItem(key);
if (saved) {
const data = JSON.parse(saved);
const match =
dailyVerse.reference.match(/\s(\d+):/);
const correctChapter = match
? parseInt(match[1], 10)
: 1;
chapterCorrect =
data.selectedChapter === correctChapter;
}
}}
/>
{/if}
<GuessesTable {guesses} {correctBookId} /> <div class="animate-fade-in-up animate-delay-600">
{#if isWon} <GuessesTable {guesses} {correctBookId} />
<Feedback /> </div>
{/if}
{#if isDev} {#if isWon}
<button <div class="animate-fade-in-up animate-delay-800">
onclick={clearLocalStorage} <Credits />
class="mt-4 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-bold transition-colors" </div>
> {/if}
Clear LocalStorage </div>
</button> <div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
{/if} <div class="flex flex-col md:flex-row gap-3">
</div> <a
href="/stats?{user ? `userId=${user.id}` : `anonymousId=${anonymousId}`}"
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>
{#if isDev}
<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: {anonymousId || 'Not set'}</div>
</div>
<DevButtons />
{/if}
</div>
</div>
</div> </div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />

View File

@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRandomVersesFromBook } from '$lib/server/xml-bible';
interface VerseOption {
text: string;
isImposter: boolean;
ref: string;
}
export const GET: RequestHandler = async () => {
try {
// Select two different random books (1-66)
let book1Num = Math.floor(Math.random() * 66) + 1;
let book2Num = Math.floor(Math.random() * 66) + 1;
while (book2Num === book1Num) {
book2Num = Math.floor(Math.random() * 66) + 1;
}
// Randomly decide which is majority
const majorityBookNum = Math.random() < 0.5 ? book1Num : book2Num;
const imposterBookNum = majorityBookNum === book1Num ? book2Num : book1Num;
// Get 3 random verses from majority book
const options: VerseOption[] = [];
for (let i = 0; i < 3; i++) {
const verseData = getRandomVersesFromBook(majorityBookNum, 1);
if (!verseData) {
throw new Error('Failed to get majority verse');
}
options.push({
text: verseData.verses[0],
isImposter: false,
ref: `${verseData.bookName} ${verseData.chapter}:${verseData.startVerse}`
});
}
// Get 1 random verse from imposter book
const imposterVerseData = getRandomVersesFromBook(imposterBookNum, 1);
if (!imposterVerseData) {
throw new Error('Failed to get imposter verse');
}
options.push({
text: imposterVerseData.verses[0],
isImposter: true,
ref: `${imposterVerseData.bookName} ${imposterVerseData.chapter}:${imposterVerseData.startVerse}`
});
// Fisher-Yates shuffle
for (let i = options.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[options[i], options[j]] = [options[j], options[i]];
}
const verses = options.map(o => o.text);
const refs = options.map(o => o.ref);
const imposterIndex = options.findIndex(o => o.isImposter);
return json({
verses,
refs,
imposterIndex
});
} catch (error) {
console.error('Imposter API error:', error);
return json({ error: 'Failed to generate imposter game' }, { status: 500 });
}
};

View File

@@ -48,13 +48,20 @@ export const POST: RequestHandler = async ({ request }) => {
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 json({ return json({
success: true, success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses } stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
}); });
} catch (err) { } catch (err) {
console.error('Error submitting completion:', err); console.error('Error submitting completion:', err);
@@ -105,13 +112,20 @@ export const GET: RequestHandler = async ({ url }) => {
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 json({ return json({
success: true, success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses } stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
}); });
} catch (err) { } catch (err) {
console.error('Error fetching stats:', err); console.error('Error fetching stats:', err);

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,110 @@
import { redirect, fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import * as auth from '$lib/server/auth';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { eq, inArray } from 'drizzle-orm';
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
if (anonymousId && anonymousId !== user.id) {
try {
// Update all daily completions from the local anonymous ID to the user's ID
await db
.update(dailyCompletions)
.set({ anonymousId: user.id })
.where(eq(dailyCompletions.anonymousId, anonymousId));
console.log(`Migrated stats from ${anonymousId} to ${user.id}`);
// Deduplicate any entries for the same date after migration
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));
console.log(`Found ${completions.length} duplicates for date ${date}, keeping earliest, deleting ${toDelete.length}`);
}
}
// Delete duplicate entries
if (duplicateIds.length > 0) {
await db
.delete(dailyCompletions)
.where(inArray(dailyCompletions.id, duplicateIds));
console.log(`Deleted ${duplicateIds.length} duplicate completion entries`);
}
} catch (error) {
console.error('Error migrating anonymous stats:', error);
// Don't fail the signin if stats migration fails
}
}
// 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

@@ -1,214 +1,17 @@
<script lang="ts"> <script lang="ts">
let sentence = $state(""); import Imposter from "$lib/components/Imposter.svelte";
let results = $state< import Container from "$lib/components/Container.svelte";
Array<{
book: string;
chapter: number;
verse: number;
text: string;
score: number;
}>
>([]);
let loading = $state(false);
async function searchVerses() {
loading = true;
try {
const response = await fetch("/api/similar-verses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sentence, topK: 10 }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
results = data.results || [];
} catch (error) {
console.error("Search error:", error);
results = [];
} finally {
loading = false;
}
}
</script> </script>
<div class="page"> <svelte:head>
<h1 class="title">Similar Verse Finder</h1> <title>Bibdle (imposter mode)</title>
</svelte:head>
<div class="search-section"> <Container>
<input <Container class="p-2 mt-12">
bind:value={sentence} <h1><i>Imposter Mode</i></h1>
placeholder="Enter a sentence to find similar Bible verses..." <p>Click the verse that doesn't belong</p>
class="input" </Container>
/>
<button onclick={searchVerses} disabled={loading} class="button">
{loading ? "Searching..." : "Find Similar Verses"}
</button>
</div>
{#if results.length > 0} <Imposter />
<div class="results"> </Container>
{#each results as result, i (i)}
<article class="result">
<header>
<strong>{result.book} {result.chapter}:{result.verse}</strong>
<span class="score">Score: {result.score.toFixed(3)}</span>
</header>
<p>{result.text}</p>
</article>
{/each}
</div>
{:else if sentence.trim() && !loading}
<p class="no-results">No similar verses found. Try another sentence!</p>
{/if}
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 1rem 0.75rem;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.title {
text-align: center;
margin-bottom: 1.75rem;
font-size: clamp(2rem, 5vw, 3rem);
color: #2c3e50;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.search-section {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.input {
flex: 1;
min-width: 300px;
padding: 0.75rem 1rem;
border: 2px solid #e1e5e9;
border-radius: 12px;
font-size: 1.1rem;
transition: all 0.2s ease;
background: #fafbfc;
}
.input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
background: white;
}
.button {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.button:disabled {
background: #a0aec0;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.result {
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 1.25rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.result:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
.result header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
gap: 0.75rem;
}
.result strong {
font-size: 1.3rem;
color: #1a202c;
}
.score {
font-size: 1rem;
color: #718096;
font-weight: 500;
white-space: nowrap;
}
.result p {
margin: 0;
line-height: 1.7;
color: #4a5568;
font-size: 1.1rem;
}
.no-results {
text-align: center;
padding: 1.75rem 0.75rem;
color: #a0aec0;
font-size: 1.2rem;
font-style: italic;
}
@media (max-width: 768px) {
.search-section {
flex-direction: column;
}
.input {
min-width: unset;
}
.result header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>

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

@@ -0,0 +1,214 @@
<script lang="ts">
let sentence = $state("");
let results = $state<
Array<{
book: string;
chapter: number;
verse: number;
text: string;
score: number;
}>
>([]);
let loading = $state(false);
async function searchVerses() {
loading = true;
try {
const response = await fetch("/api/similar-verses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sentence, topK: 10 }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
results = data.results || [];
} catch (error) {
console.error("Search error:", error);
results = [];
} finally {
loading = false;
}
}
</script>
<div class="page">
<h1 class="title">Similar Verse Finder</h1>
<div class="search-section">
<input
bind:value={sentence}
placeholder="Enter a sentence to find similar Bible verses..."
class="input"
/>
<button onclick={searchVerses} disabled={loading} class="button">
{loading ? "Searching..." : "Find Similar Verses"}
</button>
</div>
{#if results.length > 0}
<div class="results">
{#each results as result, i (i)}
<article class="result">
<header>
<strong>{result.book} {result.chapter}:{result.verse}</strong>
<span class="score">Score: {result.score.toFixed(3)}</span>
</header>
<p>{result.text}</p>
</article>
{/each}
</div>
{:else if sentence.trim() && !loading}
<p class="no-results">No similar verses found. Try another sentence!</p>
{/if}
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 1rem 0.75rem;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.title {
text-align: center;
margin-bottom: 1.75rem;
font-size: clamp(2rem, 5vw, 3rem);
color: #2c3e50;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.search-section {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.input {
flex: 1;
min-width: 300px;
padding: 0.75rem 1rem;
border: 2px solid #e1e5e9;
border-radius: 12px;
font-size: 1.1rem;
transition: all 0.2s ease;
background: #fafbfc;
}
.input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
background: white;
}
.button {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.button:disabled {
background: #a0aec0;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.result {
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 1.25rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.result:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
.result header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
gap: 0.75rem;
}
.result strong {
font-size: 1.3rem;
color: #1a202c;
}
.score {
font-size: 1rem;
color: #718096;
font-weight: 500;
white-space: nowrap;
}
.result p {
margin: 0;
line-height: 1.7;
color: #4a5568;
font-size: 1.1rem;
}
.no-results {
text-align: center;
padding: 1.75rem 0.75rem;
color: #a0aec0;
font-size: 1.2rem;
font-style: italic;
}
@media (max-width: 768px) {
.search-section {
flex-direction: column;
}
.input {
min-width: unset;
}
.result header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,249 @@
import { db } from '$lib/server/db';
import { dailyCompletions, dailyVerses, type DailyCompletion } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { bibleBooks } from '$lib/types/bible';
export const load: PageServerLoad = async ({ url, locals }) => {
// Check if user is authenticated
if (!locals.user) {
return {
stats: null,
error: null,
user: null,
session: null,
requiresAuth: true
};
}
const userId = locals.user.id;
if (!userId) {
return {
stats: null,
error: 'No user ID provided',
user: locals.user,
session: locals.session
};
}
try {
// Get all completions for this user
const completions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId))
.orderBy(desc(dailyCompletions.date));
if (completions.length === 0) {
return {
stats: {
totalSolves: 0,
avgGuesses: 0,
gradeDistribution: {
'S++': 0,
'S+': 0,
'A+': 0,
'A': 0,
'B+': 0,
'B': 0,
'C+': 0,
'C': 0
},
currentStreak: 0,
bestStreak: 0,
recentCompletions: [],
worstDay: null,
bestBook: null,
mostSeenBook: null,
totalBooksSeenOT: 0,
totalBooksSeenNT: 0
},
user: locals.user,
session: locals.session
};
}
// Calculate basic stats
const totalSolves = completions.length;
const totalGuesses = completions.reduce((sum: number, c: DailyCompletion) => sum + c.guessCount, 0);
const avgGuesses = Math.round((totalGuesses / totalSolves) * 100) / 100;
// Calculate grade distribution
const gradeDistribution = {
'S++': 0, // This will be calculated differently since we don't store chapter correctness
'S+': completions.filter((c: DailyCompletion) => c.guessCount === 1).length,
'A+': completions.filter((c: DailyCompletion) => c.guessCount === 2).length,
'A': completions.filter((c: DailyCompletion) => c.guessCount === 3).length,
'B+': completions.filter((c: DailyCompletion) => c.guessCount >= 4 && c.guessCount <= 6).length,
'B': completions.filter((c: DailyCompletion) => c.guessCount >= 7 && c.guessCount <= 10).length,
'C+': completions.filter((c: DailyCompletion) => c.guessCount >= 11 && c.guessCount <= 15).length,
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
};
// Calculate streaks
const sortedDates = completions
.map((c: DailyCompletion) => c.date)
.sort();
let currentStreak = 0;
let bestStreak = 0;
let tempStreak = 1;
if (sortedDates.length > 0) {
// Check if current streak is active (includes today or yesterday)
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const lastPlayedDate = sortedDates[sortedDates.length - 1];
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
currentStreak = 1;
// Count backwards from the most recent date
for (let i = sortedDates.length - 2; i >= 0; i--) {
const currentDate = new Date(sortedDates[i + 1]);
const prevDate = new Date(sortedDates[i]);
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) {
currentStreak++;
} else {
break;
}
}
}
// Calculate best streak
bestStreak = 1;
for (let i = 1; i < sortedDates.length; i++) {
const currentDate = new Date(sortedDates[i]);
const prevDate = new Date(sortedDates[i - 1]);
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) {
tempStreak++;
} else {
bestStreak = Math.max(bestStreak, tempStreak);
tempStreak = 1;
}
}
bestStreak = Math.max(bestStreak, tempStreak);
}
// Get recent completions (last 7 days)
const recentCompletions = completions
.slice(0, 7)
.map((c: DailyCompletion) => ({
date: c.date,
guessCount: c.guessCount,
grade: getGradeFromGuesses(c.guessCount)
}));
// Calculate worst day (highest guess count)
const worstDay = completions.reduce((max, c) =>
c.guessCount > max.guessCount ? c : max,
completions[0]
);
// Get all daily verses to link completions to books
const allVerses = await db
.select()
.from(dailyVerses);
// Create a map of date -> bookId
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
// Calculate book-specific stats
const bookStats = new Map<string, { count: number; totalGuesses: number }>();
for (const completion of completions) {
const bookId = dateToBookId.get(completion.date);
if (bookId) {
const existing = bookStats.get(bookId) || { count: 0, totalGuesses: 0 };
bookStats.set(bookId, {
count: existing.count + 1,
totalGuesses: existing.totalGuesses + completion.guessCount
});
}
}
// Find book you know the best (lowest avg guesses)
let bestBook: { bookId: string; avgGuesses: number; count: number } | null = null;
for (const [bookId, stats] of bookStats.entries()) {
const avgGuesses = stats.totalGuesses / stats.count;
if (!bestBook || avgGuesses < bestBook.avgGuesses) {
bestBook = { bookId, avgGuesses, count: stats.count };
}
}
// Find most seen book
let mostSeenBook: { bookId: string; count: number } | null = null;
for (const [bookId, stats] of bookStats.entries()) {
if (!mostSeenBook || stats.count > mostSeenBook.count) {
mostSeenBook = { bookId, count: stats.count };
}
}
// Count unique books by testament
const oldTestamentBooks = new Set<string>();
const newTestamentBooks = new Set<string>();
for (const [bookId, _] of bookStats.entries()) {
const book = bibleBooks.find(b => b.id === bookId);
if (book) {
if (book.testament === 'old') {
oldTestamentBooks.add(bookId);
} else {
newTestamentBooks.add(bookId);
}
}
}
return {
stats: {
totalSolves,
avgGuesses,
gradeDistribution,
currentStreak,
bestStreak,
recentCompletions,
worstDay: {
date: worstDay.date,
guessCount: worstDay.guessCount
},
bestBook: bestBook ? {
bookId: bestBook.bookId,
avgGuesses: Math.round(bestBook.avgGuesses * 100) / 100,
count: bestBook.count
} : null,
mostSeenBook: mostSeenBook ? {
bookId: mostSeenBook.bookId,
count: mostSeenBook.count
} : null,
totalBooksSeenOT: oldTestamentBooks.size,
totalBooksSeenNT: newTestamentBooks.size
},
user: locals.user,
session: locals.session
};
} catch (error) {
console.error('Error fetching user stats:', error);
return {
stats: null,
error: 'Failed to fetch stats',
user: locals.user,
session: locals.session
};
}
};
function getGradeFromGuesses(guessCount: number): string {
if (guessCount === 1) return "S+";
if (guessCount === 2) return "A+";
if (guessCount === 3) return "A";
if (guessCount >= 4 && guessCount <= 6) return "B+";
if (guessCount >= 7 && guessCount <= 10) return "B";
if (guessCount >= 11 && guessCount <= 15) return "C+";
return "C";
}

View File

@@ -0,0 +1,273 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { enhance } from '$app/forms';
import AuthModal from "$lib/components/AuthModal.svelte";
import Container from "$lib/components/Container.svelte";
import { bibleBooks } from "$lib/types/bible";
import {
getGradeColor,
formatDate,
getStreakMessage,
getPerformanceMessage,
type UserStats
} from "$lib/utils/stats";
interface PageData {
stats: UserStats | null;
error?: string;
user?: any;
session?: any;
requiresAuth?: boolean;
}
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let loading = $state(true);
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
onMount(async () => {
loading = false;
});
function getGradePercentage(count: number, total: number): number {
return total > 0 ? Math.round((count / total) * 100) : 0;
}
function getBookName(bookId: string): string {
return bibleBooks.find(b => b.id === bookId)?.name || bookId;
}
$inspect(data);
</script>
<svelte:head>
<title>Stats | Bibdle</title>
<meta name="description" content="View your Bibdle game statistics and performance" />
</svelte:head>
<div class="min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="text-center mb-6 md:mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">Your Stats</h1>
<p class="text-sm md:text-base text-gray-300 mb-4">Track your Bibdle performance over time</p>
<a
href="/"
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
>
← Back to Game
</a>
</div>
{#if loading}
<div class="text-center py-12">
<div class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
<p class="mt-4 text-gray-300">Loading your stats...</p>
</div>
{:else if data.requiresAuth}
<div class="text-center py-12">
<div class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm">
<h2 class="text-2xl font-bold text-blue-200 mb-4">Authentication Required</h2>
<p class="text-blue-300 mb-6">You must be logged in to see your stats.</p>
<div class="flex flex-col gap-3">
<button
onclick={() => authModalOpen = true}
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
🔐 Sign In / Sign Up
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
← Back to Game
</a>
</div>
</div>
</div>
{:else if data.error}
<div class="text-center py-12">
<div class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm">
<p class="text-red-300">{data.error}</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Return to Game
</a>
</div>
</div>
{:else if !data.stats}
<div class="text-center py-12">
<Container class="p-8 max-w-md mx-auto">
<div class="text-yellow-400 mb-4 text-lg">No stats available yet.</div>
<p class="text-gray-300 mb-6">Start playing to build your stats!</p>
<a
href="/"
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
>
Start Playing
</a>
</Container>
</div>
{:else}
{@const stats = data.stats}
<!-- Key Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
<!-- Current Streak -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1">🔥</div>
<div class="text-2xl md:text-3xl font-bold text-orange-400 mb-1">{stats.currentStreak}</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">Current Streak</div>
</div>
</Container>
<!-- Longest Streak -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1"></div>
<div class="text-2xl md:text-3xl font-bold text-purple-400 mb-1">{stats.bestStreak}</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">Best Streak</div>
</div>
</Container>
<!-- Average Guesses -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1">🎯</div>
<div class="text-2xl md:text-3xl font-bold text-blue-400 mb-1">{stats.avgGuesses}</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">Avg Guesses</div>
</div>
</Container>
<!-- Total Solves -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1"></div>
<div class="text-2xl md:text-3xl font-bold text-green-400 mb-1">{stats.totalSolves}</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">Total Solves</div>
</div>
</Container>
</div>
{#if stats.totalSolves > 0}
<!-- Book Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6">
<!-- Worst Day -->
{#if stats.worstDay}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">😅</div>
<div class="flex-1 min-w-0">
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Worst Day</div>
<div class="text-xl md:text-2xl font-bold text-red-400 truncate">{stats.worstDay.guessCount} guesses</div>
<div class="text-xs md:text-sm text-gray-400">{formatDate(stats.worstDay.date)}</div>
</div>
</div>
</Container>
{/if}
<!-- Best Book -->
{#if stats.bestBook}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">🏆</div>
<div class="flex-1 min-w-0">
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Best Book</div>
<div class="text-lg md:text-xl font-bold text-amber-400 truncate">{getBookName(stats.bestBook.bookId)}</div>
<div class="text-xs md:text-sm text-gray-400">{stats.bestBook.avgGuesses} avg guesses ({stats.bestBook.count}x)</div>
</div>
</div>
</Container>
{/if}
<!-- Most Seen Book -->
{#if stats.mostSeenBook}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">📖</div>
<div class="flex-1 min-w-0">
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Most Seen Book</div>
<div class="text-lg md:text-xl font-bold text-indigo-400 truncate">{getBookName(stats.mostSeenBook.bookId)}</div>
<div class="text-xs md:text-sm text-gray-400">{stats.mostSeenBook.count} time{stats.mostSeenBook.count === 1 ? '' : 's'}</div>
</div>
</div>
</Container>
{/if}
<!-- Total Books Seen -->
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">📚</div>
<div class="flex-1 min-w-0">
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Unique Books</div>
<div class="text-xl md:text-2xl font-bold text-teal-400">
{stats.totalBooksSeenOT + stats.totalBooksSeenNT}
</div>
<div class="text-xs md:text-sm text-gray-400">OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}</div>
</div>
</div>
</Container>
</div>
<!-- Grade Distribution -->
<Container class="p-5 md:p-6 mb-6">
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">Grade Distribution</h2>
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
{@const percentage = getGradePercentage(count, stats.totalSolves)}
<div class="text-center">
<div class="mb-2">
<span class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(grade)}">
{grade}
</span>
</div>
<div class="text-lg md:text-2xl font-bold text-gray-100">{count}</div>
<div class="text-xs text-gray-400">{percentage}%</div>
</div>
{/each}
</div>
</Container>
<!-- Recent Performance -->
{#if stats.recentCompletions.length > 0}
<Container class="p-5 md:p-6">
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">Recent Performance</h2>
<div class="space-y-2">
{#each stats.recentCompletions as completion (completion.date)}
<div class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0">
<div>
<span class="text-sm md:text-base font-medium text-gray-200">{formatDate(completion.date)}</span>
</div>
<div class="flex items-center gap-2 md:gap-3">
<span class="text-xs md:text-sm text-gray-300">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
<span class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(completion.grade)}">
{completion.grade}
</span>
</div>
</div>
{/each}
</div>
</Container>
{/if}
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} anonymousId="" />

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

55
todo.md
View File

@@ -1,21 +1,33 @@
# in progress # in progress
- root menu: classic / imposter mode / impossible mode (complete today's classic and imposter modes to unlock)
# todo # todo
- login
- login route
- impossible mode (1904 greek bible) three guesses only. - impossible mode (1904 greek bible) three guesses only.
- share both classic and impossible mode with both buttons - share both classic and impossible mode with both buttons
- add imposter mode - improve imposter mode
- Show new/old testament after 3 guesses and section after 7 guesses
- Add sections for "first letter", "Canonical/deutero", etc...
- How do you balance rewarding knowledge vs incentivising learning?
- instructions - instructions
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible. - classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
- imposter mode: out of four options, identify the verse that is not in the Bible - imposter mode: out of four options, identify the verse that is not in the Bible
- OR out of four options, identify the verse that is not in the same book as the other three options
- OR, out of four options, drag them into the
- impossible mode: identify which book of the bible the verse is from in less than three guesses. - impossible mode: identify which book of the bible the verse is from in less than three guesses.
- The gambling aspect of hoping you get a verse you already know is VERY strong
- add login + saved stats + streak etc. - add login + saved stats + streak etc.
- add deuterocanonical books - add deuterocanonical books
@@ -47,6 +59,45 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done # done
## february 2nd
- created rss feed
- fixed "first letter" clue edge cases
- updated ranking formula
## january 28th
- add percentile stats, update chapter guess UI
- fixed middle statline (removed meaningless %)
- added instructions
- added email button
- added test buttons for 3.0 UI/UX
- package upgrades
## january 26th
- Make the UI more "wordle-like"
- added deployment script (./deploy.sh)
- added bluesky button
- added "first letter" column
- added imposter mode, v0.1 (mom likes it) but needs work
## january 5th
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
- Verses ending in semicolons, commas, etc. will be replaced with "..."
## january 4th
- For bonus points: guess the verse/psalm number
- major UI styling revamp
-- 2026 --
## december 30th
- merged the embeddings/similarity route into production
## december 27th ## december 27th
- add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate) - add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate)