Compare commits

92 Commits

Author SHA1 Message Date
George Powell
a188be167b added single day history button 2026-02-26 15:04:34 -05:00
George Powell
e550965086 add unit tests for core game, bible, share, and stats utilities
146 tests covering evaluateGuess, grading, ordinals, bible data
integrity, section counts, share text generation, and stat
string helpers. Also fixes toOrdinal for 111-113 (was using
>= 11 && <= 13 instead of % 100 check).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:02:46 -05:00
George Powell
03429b17cc fix: prevent single-day streaks from being shown
A streak requires at least 2 consecutive days. Return 0 when the
count is less than 2 so the streak counter is not displayed after
completing only today's puzzle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:57:44 -05:00
George Powell
3ee7331510 remove grade distribution and recent performance sections from stats page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:40:25 -05:00
George Powell
592fa917cd added device-dependent dark mode 2026-02-26 07:27:30 -05:00
George Powell
ad1774e6b0 removed url from share, like wordle 2026-02-26 06:55:04 -05:00
George Powell
e1a665ba63 added toggle verse display and fixed timer spacing 2026-02-26 01:23:13 -05:00
George Powell
f3c9feaf97 removed verse snippet from share 2026-02-26 00:52:00 -05:00
George Powell
a5cf248e29 added streak container 2026-02-26 00:51:48 -05:00
George Powell
f9f3f3de12 Update deploy.sh 2026-02-24 19:10:53 -05:00
George Powell
abab886d1a abort if already up to date 2026-02-24 18:11:42 -05:00
George Powell
acc82af7cd updated deploy script 2026-02-24 18:04:28 -05:00
George Powell
fc674d6008 updated done list 2026-02-22 23:29:35 -05:00
George Powell
087a476df8 Remove verse reference from copied text
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 23:19:48 -05:00
George Powell
ba45cbdc37 Progressive disclosure in search dropdown based on guess count
Shows book names only (A-Z) for the first 3 guesses, reveals Old/New
Testament groupings after 3 guesses, and full section-level groupings
in canonical Bible order after 9 guesses. Adds a status banner above
the search bar to inform players when new structure becomes visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 23:19:00 -05:00
George Powell
1de436534c added copy verse text button 2026-02-22 22:50:28 -05:00
George Powell
3bcd7ea266 Fixed some things and added analytics events back 2026-02-22 22:29:35 -05:00
George Powell
7ecc84ffbc Centered main share button text 2026-02-22 19:30:35 -05:00
George Powell
3d78353a90 new share button design 2026-02-22 19:26:40 -05:00
George Powell
bd36f29419 Updated streak-percentile to count all players from the last 30 days (or
all active streaks if players have a greater than 30 day streak)
2026-02-22 00:25:08 -05:00
George Powell
3036264d44 Add Rybbit analytics alongside Umami
- Load Rybbit script via app.html (recommended SvelteKit approach)
- Mirror all Umami custom events (First guess, Guessed correctly, Share, Copy to Clipboard, social link clicks) with rybbit.event()
- Identify logged-in users with name/email traits; anonymous users by stable UUID

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

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

Key changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 17:57:29 -05:00
George Powell
96024d5048 Support authenticated users in stats and page loading 2026-02-05 17:46:53 -05:00
George Powell
86f81cf9dd Use user ID for umami identify when authenticated 2026-02-05 17:46:14 -05:00
George Powell
24a5fdbb80 added umami events on social buttons 2026-02-05 17:43:51 -05:00
George Powell
dfe1c40a8a switched to bun:sqlite 2026-02-05 00:47:55 -05:00
George Powell
dfe784b744 added login modal 2026-02-05 00:47:47 -05:00
George Powell
6bced13543 Merge pull request #2 from pupperpowell/stats
added demo stats page (needs refinement)
2026-02-04 23:36:10 -05:00
George Powell
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
89 changed files with 8254 additions and 1232 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-----"

6
.gitignore vendored
View File

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

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

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="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, Where is their God?</verse>
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
@@ -25056,7 +25056,7 @@
<chapter number="3">
<verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse>
<verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a 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="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse>
<verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse>
@@ -33616,4 +33616,4 @@
</chapter>
</book>
</testament>
</bible>
</bible>

View File

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

View File

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

26
deploy.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
BUN=$(which bun)
echo "Pulling latest changes..."
PULL_OUTPUT=$(git pull)
echo "$PULL_OUTPUT"
if [ "$PULL_OUTPUT" = "Already up to date." ]; then
echo "Nothing to deploy."
exit 0
fi
echo "Installing dependencies..."
$BUN i
echo "Building..."
$BUN --bun run build
SERVICE_NAME="$(basename "$(pwd)").service"
echo "Restarting service ($SERVICE_NAME)..."
sudo systemctl restart "$SERVICE_NAME"
echo "Done!"

11
drizzle.test.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'sqlite',
dbCredentials: { url: process.env.TEST_DATABASE_URL },
verbose: true,
strict: true
});

View File

@@ -0,0 +1,10 @@
ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint
ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint
ALTER TABLE `user` ADD `email` text;--> statement-breakpoint
ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint
ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint
ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint
CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`);

View File

@@ -0,0 +1,275 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
"prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d",
"tables": {
"daily_completions": {
"name": "daily_completions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"anonymous_id": {
"name": "anonymous_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guess_count": {
"name": "guess_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"anonymous_id_date_idx": {
"name": "anonymous_id_date_idx",
"columns": [
"anonymous_id",
"date"
],
"isUnique": false
},
"date_idx": {
"name": "date_idx",
"columns": [
"date"
],
"isUnique": false
},
"date_guess_idx": {
"name": "date_guess_idx",
"columns": [
"date",
"guess_count"
],
"isUnique": false
},
"daily_completions_anonymous_id_date_unique": {
"name": "daily_completions_anonymous_id_date_unique",
"columns": [
"anonymous_id",
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"daily_verses": {
"name": "daily_verses",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"book_id": {
"name": "book_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verse_text": {
"name": "verse_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"daily_verses_date_unique": {
"name": "daily_verses_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"apple_id": {
"name": "apple_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_private": {
"name": "is_private",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_apple_id_unique": {
"name": "user_apple_id_unique",
"columns": [
"apple_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

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

View File

@@ -1,15 +1,17 @@
{
"name": "bibdle",
"private": true,
"version": "0.0.1",
"version": "3.0.0alpha",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev": "bun --bun vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "bun test",
"test:watch": "bun test --watch",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -18,24 +20,23 @@
"devDependencies": {
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22",
"@tailwindcss/vite": "^4.1.18",
"@types/bun": "^1.3.8",
"@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.0",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"drizzle-orm": "^0.45.1",
"svelte": "^5.48.5",
"svelte-check": "^4.3.5",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.3.1"
},
"dependencies": {
"@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.5.0",
"fast-xml-parser": "^5.3.3",
"xml2js": "^0.6.2"
}

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

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

20
scripts/clear-today-verse.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/zsh
# Clear today's verse from daily_verses table
DB_PATH="dev.db"
TODAY=$(date +%Y-%m-%d)
echo "Deleting verse for date: $TODAY"
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
if [ $? -eq 0 ]; then
echo "✓ Successfully deleted verse for $TODAY"
# Show remaining verses in table
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
echo "Remaining verses in database: $COUNT"
else
echo "✗ Failed to delete verse"
exit 1
fi

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env zsh
DB_PATH="./local.db"
# Check if database exists
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
exit 1
fi
# Query for daily completions on 2026-02-01 with ranking
echo "Daily Completions for 2026-02-01"
echo "================================="
echo ""
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
# Execute query with custom column mode
sqlite3 "$DB_PATH" <<SQL
.mode column
.headers off
.width 12 10 6
SELECT
SUBSTR(anonymous_id, 1, 10) as anon_id,
guess_count,
RANK() OVER (ORDER BY guess_count ASC) as rank
FROM daily_completions
WHERE date = '2026-02-01'
ORDER BY rank, guess_count;
SQL
echo ""
echo "Total entries:"
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"

View File

@@ -0,0 +1,41 @@
import { Database } from 'bun:sqlite';
import path from 'path';
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error('DATABASE_URL is not set');
const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice(5) : dbUrl;
const db = new Database(path.resolve(dbPath));
const duplicates = db.query(`
SELECT anonymous_id, date, COUNT(*) as count
FROM daily_completions
GROUP BY anonymous_id, date
HAVING count > 1
`).all() as { anonymous_id: string; date: string; count: number }[];
if (duplicates.length === 0) {
console.log('No duplicates found.');
process.exit(0);
}
console.log(`Found ${duplicates.length} duplicate group(s):`);
const deleteStmt = db.query(`
DELETE FROM daily_completions
WHERE anonymous_id = $anonymous_id AND date = $date
AND id NOT IN (
SELECT id FROM daily_completions
WHERE anonymous_id = $anonymous_id AND date = $date
ORDER BY completed_at ASC
LIMIT 1
)
`);
for (const { anonymous_id, date, count } of duplicates) {
deleteStmt.run({ $anonymous_id: anonymous_id, $date: date });
console.log(` ${anonymous_id} / ${date}: kept earliest, deleted ${count - 1} row(s) (had ${count})`);
}
console.log('Done.');
db.close();

View File

@@ -0,0 +1,75 @@
import Database from 'bun:sqlite';
// Database path - adjust if your database is located elsewhere
const dbPath = Bun.env.DATABASE_URL || './local.db';
console.log(`Connecting to database: ${dbPath}`);
const db = new Database(dbPath);
interface DuplicateGroup {
anonymous_id: string;
date: string;
count: number;
}
interface Completion {
id: string;
anonymous_id: string;
date: string;
guess_count: number;
completed_at: number;
}
console.log('Finding duplicates...\n');
// Find all (anonymous_id, date) pairs with duplicates
const duplicatesQuery = db.query<DuplicateGroup, []>(`
SELECT anonymous_id, date, COUNT(*) as count
FROM daily_completions
GROUP BY anonymous_id, date
HAVING count > 1
`);
const duplicates = duplicatesQuery.all();
console.log(`Found ${duplicates.length} duplicate groups\n`);
if (duplicates.length === 0) {
console.log('No duplicates to clean up!');
db.close();
process.exit(0);
}
let totalDeleted = 0;
// Process each duplicate group
for (const dup of duplicates) {
// Get all completions for this (anonymous_id, date) pair
const completionsQuery = db.query<Completion, [string, string]>(`
SELECT id, anonymous_id, date, guess_count, completed_at
FROM daily_completions
WHERE anonymous_id = ? AND date = ?
ORDER BY completed_at ASC
`);
const completions = completionsQuery.all(dup.anonymous_id, dup.date);
console.log(` ${dup.anonymous_id} on ${dup.date}: ${completions.length} entries`);
// Keep the first (earliest completion), delete the rest
const toKeep = completions[0];
const toDelete = completions.slice(1);
console.log(` Keeping: ${toKeep.id} (completed at ${new Date(toKeep.completed_at * 1000).toISOString()})`);
const deleteQuery = db.query('DELETE FROM daily_completions WHERE id = ?');
for (const comp of toDelete) {
console.log(` Deleting: ${comp.id} (completed at ${new Date(comp.completed_at * 1000).toISOString()})`);
deleteQuery.run(comp.id);
totalDeleted++;
}
}
console.log(`\n✅ Deduplication complete!`);
console.log(`Total records deleted: ${totalDeleted}`);
console.log(`Unique completions preserved: ${duplicates.length}`);
db.close();

View File

@@ -0,0 +1,26 @@
#!/bin/zsh
# Seed the database with 10 fake completions with random anonymous_ids
# Useful for testing streak percentile and stats features
DB_PATH="dev.db"
TODAY=$(date +%Y-%m-%d)
NOW=$(date +%s)
echo "Seeding 10 fake completions for date: $TODAY"
for i in {1..50}; do
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
ANON_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
GUESS_COUNT=$(( (RANDOM % 6) + 1 )) # 16 guesses
sqlite3 "$DB_PATH" "
INSERT OR IGNORE INTO daily_completions (id, anonymous_id, date, guess_count, completed_at)
VALUES ('$ID', '$ANON_ID', '$TODAY', $GUESS_COUNT, $NOW);
"
echo " [$i] anon=$ANON_ID guesses=$GUESS_COUNT"
done
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '$TODAY';")
echo "✓ Done. Total completions for $TODAY: $TOTAL"

View File

@@ -0,0 +1,41 @@
import { fetchRandomVerse } from '../src/lib/server/bible-api';
import { generateShareText } from '../src/lib/utils/share';
import { bibleBooks } from '../src/lib/types/bible';
const NUM_VERSES = 10;
for (let i = 0; i < NUM_VERSES; i++) {
const verse = await fetchRandomVerse();
// Build a fake "solved in N guesses" scenario with some wrong guesses first
const correctBook = bibleBooks.find((b) => b.id === verse.bookId)!;
const wrongBook = bibleBooks.find((b) => b.id !== verse.bookId)!;
const guessCount = Math.floor(Math.random() * 5) + 1;
const guesses = [
...Array(guessCount - 1).fill(null).map(() => ({
book: wrongBook,
testamentMatch: wrongBook.testament === correctBook.testament,
sectionMatch: wrongBook.section === correctBook.section,
adjacent: Math.abs(wrongBook.order - correctBook.order) === 1,
})),
{ book: correctBook, testamentMatch: true, sectionMatch: true, adjacent: false },
];
const fakeStreak = Math.random() > 0.5 ? Math.floor(Math.random() * 14) + 2 : 0;
const shareText = generateShareText({
guesses,
correctBookId: verse.bookId,
dailyVerseDate: new Date().toISOString().slice(0, 10),
chapterCorrect: guessCount === 1 && Math.random() > 0.5,
isLoggedIn: Math.random() > 0.5,
streak: fakeStreak > 0 ? fakeStreak : undefined,
origin: 'https://bibdle.com',
verseText: verse.verseText,
});
console.log(`\n── Verse ${i + 1}: ${verse.reference} ──`);
console.log(`RAW: ${verse.verseText}`);
console.log('─'.repeat(40));
console.log(shareText);
}

View File

@@ -0,0 +1,10 @@
import { fetchRandomVerse } from '../src/lib/server/bible-api';
import { getVerseSnippet } from '../src/lib/utils/share';
const NUM_VERSES = 10;
for (let i = 0; i < NUM_VERSES; i++) {
const verse = await fetchRandomVerse();
console.log(getVerseSnippet(verse.verseText));
}

View File

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

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,236 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { browser } from '$app/environment';
import Container from './Container.svelte';
let {
isOpen = $bindable(),
anonymousId = ''
}: {
isOpen: boolean;
anonymousId: string;
} = $props();
let mode = $state<'signin' | 'signup'>('signin');
let loading = $state(false);
let error = $state('');
let success = $state('');
let email = $state('');
let password = $state('');
let firstName = $state('');
let lastName = $state('');
function resetForm() {
email = '';
password = '';
firstName = '';
lastName = '';
error = '';
success = '';
}
function switchMode() {
mode = mode === 'signin' ? 'signup' : 'signin';
resetForm();
}
function closeModal() {
isOpen = false;
resetForm();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeModal();
}
}
function handleSubmit() {
loading = true;
error = '';
success = '';
}
function handleResult(event: any) {
loading = false;
const result = event.result;
if (result.type === 'success') {
if (result.data?.success) {
success = mode === 'signin' ? 'Signed in successfully!' : 'Account created successfully!';
setTimeout(() => {
if (browser) {
window.location.reload();
}
}, 1000);
} else if (result.data?.error) {
error = result.data.error;
}
} else if (result.type === 'failure') {
error = result.data?.error || 'An error occurred. Please try again.';
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<Container class="w-full max-w-md p-6 relative">
<button
type="button"
onclick={closeModal}
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl leading-none"
>
×
</button>
<div class="mb-6">
<h2 class="text-2xl font-bold text-white">
{mode === 'signin' ? 'Sign In' : 'Create Account'}
</h2>
</div>
<form method="POST" action="/auth/apple">
<input type="hidden" name="anonymousId" value={anonymousId} />
<button
type="submit"
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>
Sign in with Apple
</button>
</form>
<div class="flex items-center my-4">
<div class="flex-1 border-t border-white/20"></div>
<span class="px-3 text-sm text-white/60">or</span>
<div class="flex-1 border-t border-white/20"></div>
</div>
<form
method="POST"
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
use:enhance={({ formData }) => {
if (anonymousId) {
formData.append('anonymousId', anonymousId);
}
handleSubmit();
return handleResult;
}}
>
<div class="space-y-4">
{#if mode === 'signup'}
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-medium text-white mb-1">
First Name
</label>
<input
id="firstName"
name="firstName"
type="text"
bind:value={firstName}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
placeholder="John"
/>
</div>
<div>
<label for="lastName" class="block text-sm font-medium text-white mb-1">
Last Name
</label>
<input
id="lastName"
name="lastName"
type="text"
bind:value={lastName}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
placeholder="Doe"
/>
</div>
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-white mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
bind:value={email}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
placeholder="john@example.com"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-white mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
bind:value={password}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
placeholder="••••••••"
minlength="6"
/>
{#if mode === 'signup'}
<p class="text-xs text-white/80 mt-1">Minimum 6 characters</p>
{/if}
</div>
</div>
{#if error}
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p class="text-sm text-red-600">{error}</p>
</div>
{/if}
{#if success}
<div class="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
<p class="text-sm text-green-600">{success}</p>
</div>
{/if}
<button
type="submit"
disabled={loading}
class="w-full mt-6 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{#if loading}
<span class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
</span>
{:else}
{mode === 'signin' ? 'Sign In' : 'Create Account'}
{/if}
</button>
</form>
<div class="mt-6 text-center">
<p class="text-sm text-white">
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
<button
type="button"
onclick={switchMode}
class="text-blue-300 hover:text-blue-200 font-medium ml-1"
>
{mode === 'signin' ? 'Create one' : 'Sign in'}
</button>
</p>
</div>
</Container>
</div>
{/if}

View File

@@ -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 4 random chapter options including the correct one
function generateChapterOptions(
correctChapter: number,
totalChapters: number,
): number[] {
const options = new Set<number>();
options.add(correctChapter);
if (totalChapters >= 4) {
while (options.size < 4) {
const randomChapter =
Math.floor(Math.random() * totalChapters) + 1;
options.add(randomChapter);
}
} else {
while (options.size < 4) {
const randomChapter = Math.floor(Math.random() * 10) + 1;
options.add(randomChapter);
}
}
return Array.from(options).sort(() => Math.random() - 0.5);
}
let correctChapter = $derived(parseChapterFromReference(reference));
let totalChapters = $derived(getChapterCount(bookId));
let chapterOptions = $state<number[]>([]);
$effect(() => {
if (chapterOptions.length === 0) {
chapterOptions = generateChapterOptions(
correctChapter,
totalChapters,
);
}
});
let selectedChapter = $state<number | null>(null);
let hasAnswered = $state(false);
// Load saved state from localStorage
$effect(() => {
if (!browser) return;
const key = `bibdle-chapter-guess-${reference}`;
const saved = localStorage.getItem(key);
if (saved) {
const data = JSON.parse(saved);
selectedChapter = data.selectedChapter;
hasAnswered = data.hasAnswered;
chapterOptions = data.chapterOptions ?? [];
}
});
// Save state to localStorage whenever options are generated or answer given
$effect(() => {
if (!browser || chapterOptions.length === 0) return;
const key = `bibdle-chapter-guess-${reference}`;
localStorage.setItem(
key,
JSON.stringify({ selectedChapter, hasAnswered, chapterOptions }),
);
});
function handleChapterSelect(chapter: number) {
if (hasAnswered) return;
selectedChapter = chapter;
hasAnswered = true;
if (onCompleted) {
onCompleted();
}
}
let isCorrect = $derived(
selectedChapter !== null && selectedChapter === correctChapter,
);
</script>
<Container
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 dark:from-amber-900/40 dark:to-yellow-900/30 text-gray-800 dark:text-gray-100 shadow-md"
>
<div class="text-center">
<p class="font-bold mb-3 text-lg sm:text-xl">
Bonus Challenge
<span class="text-base sm:text-lg opacity-60 font-normal"
>— guess the chapter for an even higher grade</span
>
</p>
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
{#each chapterOptions as chapter (chapter)}
<button
onclick={() => handleChapterSelect(chapter)}
disabled={hasAnswered}
class={`
w-20 h-20 sm:w-24 sm:h-24 text-2xl sm:text-3xl font-bold rounded-xl
transition-all duration-300 border-2
${
hasAnswered
? chapter === correctChapter
? "bg-green-500 text-white border-green-600 shadow-lg"
: selectedChapter === chapter
? isCorrect
? "bg-green-500 text-white border-green-600 shadow-lg"
: "bg-red-400 text-white border-red-500"
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
: "bg-white/80 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 text-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 hover:shadow-md cursor-pointer"
}
`}
>
{chapter}
</button>
{/each}
</div>
{#if hasAnswered}
<p
class="text-xl sm:text-2xl font-bold mb-2"
class:text-green-600={isCorrect}
class:text-red-600={!isCorrect}
>
{isCorrect ? "✓ Correct!" : "✗ Incorrect"}
</p>
<p class="text-sm opacity-80">
The verse is from chapter {correctChapter}
</p>
{#if isCorrect}
<p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p>
{/if}
{/if}
</div>
</Container>

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 dark:bg-white/5 backdrop-blur-sm rounded-2xl border border-white/20 dark:border-white/10 shadow-sm {className}"
>
{@render children()}
</div>

View File

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

View File

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

View File

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

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

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

View File

@@ -1,55 +1,309 @@
<script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible";
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
import { SvelteSet } from "svelte/reactivity";
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
let {
searchQuery = $bindable(""),
guessedIds,
submitGuess,
guessCount = 0,
}: {
searchQuery: string;
guessedIds: SvelteSet<string>;
submitGuess: (id: string) => void;
guessCount: number;
} = $props();
let filteredBooks = $derived(
bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
);
type DisplayMode = "simple" | "testament" | "sections";
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && filteredBooks.length > 0) {
submitGuess(filteredBooks[0].id);
}
}
const displayMode = $derived<DisplayMode>(
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
);
const filteredBooks = $derived(
bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase())
)
);
type SimpleGroup = { books: BibleBook[] };
type TestamentGroup = {
testament: Testament;
label: string;
books: BibleBook[];
};
type SectionGroup = {
testament: Testament;
testamentLabel: string;
showTestamentHeader: boolean;
section: BibleSection;
books: BibleBook[];
};
const simpleGroup = $derived.by<SimpleGroup>(() => {
const sorted = [...filteredBooks].sort((a, b) =>
a.name.localeCompare(b.name)
);
return { books: sorted };
});
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
const old = filteredBooks
.filter((b) => b.testament === "old")
.sort((a, b) => a.name.localeCompare(b.name));
const newT = filteredBooks
.filter((b) => b.testament === "new")
.sort((a, b) => a.name.localeCompare(b.name));
const groups: TestamentGroup[] = [];
if (old.length > 0) {
groups.push({ testament: "old", label: "Old Testament", books: old });
}
if (newT.length > 0) {
groups.push({ testament: "new", label: "New Testament", books: newT });
}
return groups;
});
const sectionGroups = $derived.by<SectionGroup[]>(() => {
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
const seenKeys: Record<string, true> = {};
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
for (const book of bibleBooks) {
const key = `${book.testament}:${book.section}`;
if (!seenKeys[key]) {
seenKeys[key] = true;
orderedPairs.push({ testament: book.testament, section: book.section });
}
}
const groups: SectionGroup[] = [];
let lastTestament: Testament | null = null;
for (const pair of orderedPairs) {
const books = filteredBooks.filter(
(b) => b.testament === pair.testament && b.section === pair.section
);
if (books.length === 0) continue;
const showTestamentHeader = pair.testament !== lastTestament;
lastTestament = pair.testament;
groups.push({
testament: pair.testament,
testamentLabel:
pair.testament === "old" ? "Old Testament" : "New Testament",
showTestamentHeader,
section: pair.section,
books,
});
}
return groups;
});
// First book in display order for Enter key submission
const firstBookId = $derived.by<string | null>(() => {
if (filteredBooks.length === 0) return null;
if (displayMode === "simple") {
return simpleGroup.books[0]?.id ?? null;
}
if (displayMode === "testament") {
return testamentGroups[0]?.books[0]?.id ?? null;
}
return sectionGroups[0]?.books[0]?.id ?? null;
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && firstBookId) {
submitGuess(firstBookId);
}
}
const showBanner = $derived(guessCount >= 3);
const bannerIsIndigo = $derived(guessCount >= 9);
</script>
<div class="mb-12">
<input
bind:value={searchQuery}
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
class="w-full p-4 sm:p-6 border-2 border-gray-200 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all shadow-lg"
onkeydown={handleKeydown}
/>
{#if searchQuery && filteredBooks.length > 0}
<ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-2xl shadow-lg"
>
{#each filteredBooks as book (book.id)}
<li>
<button
class="w-full p-4 sm:p-5 text-left {guessedIds.has(
book.id,
)
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
onclick={() => submitGuess(book.id)}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-500'
: ''}">{book.name}</span
>
<span class="ml-auto text-sm opacity-75"
>({book.testament.toUpperCase()})</span
>
</button>
</li>
{/each}
</ul>
{:else if searchQuery}
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
{/if}
{#if showBanner}
<p
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
role="status"
aria-live="polite"
>
{#if bannerIsIndigo}
Testament &amp; section groups now visible
{:else}
Old &amp; New Testament groups now visible
{/if}
</p>
{/if}
<div class="relative">
<div class="relative">
<svg
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:value={searchQuery}
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
onkeydown={handleKeydown}
autocomplete="off"
/>
{#if searchQuery}
<button
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
onclick={() => (searchQuery = "")}
aria-label="Clear search"
>
<svg
class="w-5 h-5 sm:w-6 sm:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{#if searchQuery && filteredBooks.length > 0}
<ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
role="listbox"
>
{#if displayMode === "simple"}
{#each simpleGroup.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 hover:text-blue-700'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold dark:text-gray-100 {guessedIds.has(book.id)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
{:else if displayMode === "testament"}
{#each testamentGroups as group (group.testament)}
<li role="presentation">
<div
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
>
{group.label}
</span>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
</div>
<ul>
{#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
</ul>
</li>
{/each}
{:else}
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
<li role="presentation">
{#if group.showTestamentHeader}
<div
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{group.testamentLabel}
</span>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
</div>
{/if}
<div
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
{group.section}
</span>
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div>
</div>
<ul>
{#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
</ul>
</li>
{/each}
{/if}
</ul>
{:else if searchQuery}
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
{/if}
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
let {
streak,
streakPercentile = null,
}: {
streak: number;
streakPercentile?: number | null;
} = $props();
</script>
<div
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm flex-1 text-center"
>
<p
class="text-5xl font-triodion font-black text-orange-500 leading-none tabular-nums"
>
{streak}
</p>
<p
class="text-xs uppercase justify-center tracking-widest text-gray-500 dark:text-gray-200 font-triodion font-bold py-2 leading-tight"
>
day{streak === 1 ? "" : "s"} in a row
</p>
{#if streakPercentile !== null && streakPercentile <= 50}
<p
class="text-xs text-black dark:text-gray-200 w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 dark:border-t-stone-600 pt-2"
>
Top {streakPercentile}%
</p>
{/if}
</div>

View File

@@ -1,25 +1,87 @@
<script lang="ts">
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
import { browser } from "$app/environment";
import { fade } from "svelte/transition";
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
import Container from "./Container.svelte";
let { data, isWon }: { data: PageData; isWon: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived(
dailyVerse.reference.replace(/^Psalms /, "Psalm ")
);
let displayVerseText = $derived(
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase())
);
let {
data,
isWon,
blurChapter = false,
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived(
blurChapter
? dailyVerse.reference
.replace(/^Psalms /, "Psalm ")
.replace(/\s(\d+):/, " ?:")
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
);
let displayVerseText = $derived(
dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase())
.replace(/[,:;-—]$/, "..."),
);
let showReference = $state(false);
let copied = $state(false);
// Delay showing reference until GuessesTable animation completes
$effect(() => {
if (!isWon) {
showReference = false;
return;
}
// Check if user already won today (page reload case)
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
const alreadyWonToday =
browser && localStorage.getItem(winTrackedKey) === "true";
if (alreadyWonToday) {
// User already won and is refreshing - show immediately
showReference = true;
} else {
// User just won this session - delay for animation
const animationDelay = 1800;
const timeoutId = setTimeout(() => {
showReference = true;
}, animationDelay);
return () => clearTimeout(timeoutId);
}
});
function copyVerse() {
navigator.clipboard.writeText(displayVerseText).then(() => {
copied = true;
(window as any).rybbit?.event("Copy Verse");
setTimeout(() => {
copied = false;
}, 2000);
});
}
</script>
<div class="bg-gray-50 rounded-2xl shadow-xl p-8 sm:p-12 mb-4 sm:mb-12 w-full">
<blockquote
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
>
{displayVerseText}
</blockquote>
{#if isWon}
<p class="text-center text-lg! big-text text-green-600! font-bold mt-8">
{displayReference}
</p>
{/if}
</div>
<Container
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
>
<blockquote
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
>
{displayVerseText}
</blockquote>
<div
class="transition-all duration-500 ease-in-out overflow-hidden"
style="max-height: {showReference ? '200px' : '0px'};"
>
{#if showReference}
<p
transition:fade={{ duration: 400 }}
class="text-center text-lg! big-text text-green-600! dark:text-green-400! font-bold mt-8 bg-white/70 dark:bg-black/50 rounded-xl px-4 py-2"
>
{displayReference}
</p>
{/if}
</div>
</Container>

View File

@@ -1,196 +1,601 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { getBookById, toOrdinal, getNextGradeMessage } from "$lib/utils/game";
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { getBookById, toOrdinal } from "$lib/utils/game";
import {
getVerseSnippet,
shareResult,
copyToClipboard as clipboardCopy,
} from "$lib/utils/share";
import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte";
import StreakCounter from "./StreakCounter.svelte";
import ChapterGuess from "./ChapterGuess.svelte";
interface StatsData {
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
}
interface StatsData {
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
tiedCount: number;
percentile: number;
}
interface WeightedMessage {
text: string;
weight: number;
}
interface WeightedMessage {
text: string;
weight: number;
}
let {
grade,
statsData,
correctBookId,
handleShare,
copyToClipboard,
copied = $bindable(false),
statsSubmitted,
guessCount,
} = $props();
let {
statsData,
correctBookId,
handleShare,
copyToClipboard,
copied = $bindable(false),
statsSubmitted,
guessCount,
reference,
onChapterGuessCompleted,
shareText,
verseText,
streak = 0,
streakPercentile = null,
}: {
statsData: StatsData | null;
correctBookId: string;
handleShare: () => void;
copyToClipboard: () => void;
copied: boolean;
statsSubmitted: boolean;
guessCount: number;
reference: string;
onChapterGuessCompleted: () => void;
shareText: string;
verseText: string;
streak?: number;
streakPercentile?: number | null;
} = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
let hasWebShare = $derived(
typeof navigator !== "undefined" && "share" in navigator
);
let copySuccess = $state(false);
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
let hasWebShare = $derived(
typeof navigator !== "undefined" && "share" in navigator,
);
let copySuccess = $state(false);
let bubbleCopied = $state(false);
let copyTracked = $state(false);
let showSnippetOption = $state(false);
let includeSnippet = $state(false);
// List of congratulations messages with weights
const congratulationsMessages: WeightedMessage[] = [
{ text: "Congratulations!", weight: 10 },
{ text: "You got it!", weight: 1000 },
{ text: "Yup,", weight: 100 },
{ text: "Very nice!", weight: 1 },
];
let effectiveShareText = $derived(
includeSnippet
? (() => {
const snippet = getVerseSnippet(verseText);
const lines = shareText.split("\n");
return [
...lines.slice(0, -1),
snippet,
lines[lines.length - 1],
].join("\n");
})()
: shareText,
);
// Function to select a random message based on weights
function getRandomCongratulationsMessage(): string {
// Special case for first try success
if (guessCount === 1) {
const n = Math.random();
if (n < 0.99) {
return "🌟 First try! 🌟";
} else {
return "🗣️ Axios! 🗣️";
}
}
// List of congratulations messages with weights
const congratulationsMessages: WeightedMessage[] = [
{ text: "Congratulations!", weight: 10 },
{ text: "You got it!", weight: 1000 },
{ text: "Yup.", weight: 100 },
{ text: "Very nice!", weight: 1 },
];
const totalWeight = congratulationsMessages.reduce(
(sum, msg) => sum + msg.weight,
0
);
let random = Math.random() * totalWeight;
// Function to select a random message based on weights
function getRandomCongratulationsMessage(): string {
// Special case for first try success
if (guessCount === 1) {
const n = Math.random();
if (n < 0.99) {
return "First try!";
} else {
return "Axios!";
}
}
for (const message of congratulationsMessages) {
random -= message.weight;
if (random <= 0) {
return message.text;
}
}
const totalWeight = congratulationsMessages.reduce(
(sum, msg) => sum + msg.weight,
0,
);
let random = Math.random() * totalWeight;
// Fallback to first message if something goes wrong
return congratulationsMessages[0].text;
}
for (const message of congratulationsMessages) {
random -= message.weight;
if (random <= 0) {
return message.text;
}
}
// Generate the congratulations message
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
// Fallback to first message if something goes wrong
return congratulationsMessages[0].text;
}
// Generate the congratulations message
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
</script>
<div
class="p-8 sm:p-12 w-full bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center fade-in"
>
<!-- <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg">
{congratulationsMessage}
</h2> -->
<p class="text-xl sm:text-3xl md:text-4xl">
{congratulationsMessage} The verse is from
<span class="font-black text-xl sm:text-2xl md:text-3xl">{bookName}</span>.
</p>
<p
class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block"
>
Your grade: {grade}
</p>
<div class="flex flex-col gap-6">
<Container
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
>
<div class="flex flex-col gap-3">
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
{congratulationsMessage} The verse is from
<span class="font-black font-triodion text-3xl md:text-4xl"
>{bookName}</span
>.
</p>
<p class="text-lg sm:text-xl md:text-2xl">
You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}.
</p>
<!-- {#if streak >= 7}
<p
class="italic tracking-wider px-8 font-semibold text-gray-500"
>
Thank you for making BIBDLE part of your daily routine!
</p>
{/if} -->
</div>
</Container>
{#if hasWebShare}
<button
onclick={handleShare}
data-umami-event="Share"
class="mt-4 text-2xl font-bold p-2 bg-white/20 hover:bg-white/30 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
>
📤 Share
</button>
<button
onclick={() => {
copyToClipboard();
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}}
data-umami-event="Copy to Clipboard"
class={`mt-4 text-2xl font-bold p-2 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
copySuccess
? "bg-green-400/50 hover:bg-green-500/60"
: "bg-white/20 hover:bg-white/30"
}`}
>
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
</button>
{:else}
<button
onclick={handleShare}
data-umami-event="Share"
class={`mt-4 text-2xl font-bold p-2 ${
copied
? "bg-green-400/50 hover:bg-green-500/60"
: "bg-white/20 hover:bg-white/30"
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
>
{copied ? "Copied to clipboard!" : "📤 Share"}
</button>
{/if}
<!-- S++ Bonus Challenge for first try -->
{#if guessCount === 1}
<ChapterGuess
{reference}
bookId={correctBookId}
onCompleted={onChapterGuessCompleted}
/>
{/if}
<p class="pt-6 big-text text-gray-100!">
{getNextGradeMessage(guessCount)}
</p>
<div class="flex flex-row gap-3 items-stretch w-full">
<div class="flex-2 min-w-0 flex flex-col">
<CountdownTimer />
</div>
{#if streak > 0}
<div class="flex-1 min-w-0 flex flex-col">
<StreakCounter {streak} {streakPercentile} />
</div>
{/if}
</div>
<!-- Statistics Display -->
{#if statsData}
<div class="mt-6" in:fade={{ delay: 800 }}>
<div class="grid grid-cols-3 gap-4 gap-x-8 text-center">
<!-- Solve Rank Column -->
<div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black">
#{statsData.solveRank}
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
You were the {toOrdinal(statsData.solveRank)} person to solve today
</div>
</div>
<!-- Statistics Display -->
{#if statsData}
<Container
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
>
<div
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
in:fade={{ delay: 800 }}
>
<!-- Solve Rank Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
#{statsData.solveRank}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
You were the {toOrdinal(statsData.solveRank)} person to solve
today
</div>
</div>
<!-- Guess Rank Column -->
<div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black">
{Math.round(
((statsData.totalSolves - statsData.guessRank + 1) /
statsData.totalSolves) *
100
)}%
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
total solves
</div>
</div>
<!-- Guess Rank Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
{toOrdinal(statsData.guessRank)}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
{statsData.totalSolves === 1
? "solve"
: "solves"}{statsData.tiedCount > 0
? `, tied with ${statsData.tiedCount} ${statsData.tiedCount === 1 ? "other" : "others"}`
: ""}.<br />
{#if statsData.percentile <= 25}
<span class="font-bold">
(Top {statsData.percentile}%)
</span>
{/if}
</div>
</div>
<!-- Average Column -->
<div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black">
{statsData.averageGuesses}
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
People guessed correctly after {statsData.averageGuesses}
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on average
</div>
</div>
</div>
</div>
{:else if !statsSubmitted}
<div class="mt-6 text-sm opacity-80">Submitting stats...</div>
{/if}
<!-- Average Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
{statsData.averageGuesses}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
People solved after {statsData.averageGuesses}
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on
average
</div>
</div>
</div>
</Container>
{:else if !statsSubmitted}
<Container
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
>
<div class="text-sm opacity-80">Submitting stats...</div>
</Container>
{/if}
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
<div class="big-text font-black! text-center">Share your result</div>
<div class="chat-window">
<!-- Received bubble: primary action (share / copy) -->
<div class="bubble-wrapper received-wrapper">
<button
class="bubble bubble-received"
class:success={copySuccess}
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
data-umami-event={hasWebShare
? "Share"
: "Copy to Clipboard"}
onclick={() => {
if (hasWebShare) {
(window as any).rybbit?.event("Share");
shareResult(effectiveShareText);
} else {
if (!copyTracked) {
(window as any).rybbit?.event(
"Copy to Clipboard",
);
copyTracked = true;
}
clipboardCopy(effectiveShareText);
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}
}}
>
{#if hasWebShare}
📤 Tap here to share
{:else if copySuccess}
✅ Copied!
{:else}
📋 Copy to clipboard
{/if}
</button>
</div>
<!-- Sent bubble: share text preview -->
<div class="bubble-wrapper">
<button
class="bubble bubble-sent"
aria-label="Copy to clipboard"
data-umami-event="Copy to Clipboard"
onclick={() => {
if (!copyTracked) {
(window as any).rybbit?.event("Copy to Clipboard");
copyTracked = true;
}
clipboardCopy(effectiveShareText);
showSnippetOption = true;
bubbleCopied = true;
setTimeout(() => {
bubbleCopied = false;
}, 2000);
}}>{effectiveShareText}</button
>
{#if hasWebShare}
<span class="copy-hint"
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
>
{:else}
<span class="copy-hint"
>{bubbleCopied ? "copied!" : ""}</span
>
{/if}
</div>
</div>
</div>
{#if showSnippetOption}
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
<span class="snippet-label">Show verse snippet in share?</span>
<button
class="snippet-toggle"
class:on={includeSnippet}
onclick={() => (includeSnippet = !includeSnippet)}
aria-pressed={includeSnippet}
aria-label="Show snippet in share"
>
<span class="toggle-thumb"></span>
</button>
</div>
{/if}
</div>
<style>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
:global(.fade-in) {
animation: fadeIn 0.5s ease-out;
}
/* ── Share card ── */
.share-card {
background: oklch(94% 0.028 298.626);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 1.25rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
.share-card {
background: oklch(22% 0.025 298.626);
border-color: rgba(255, 255, 255, 0.1);
}
}
.share-card::before {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
opacity: 0.04;
pointer-events: none;
}
/* ── Chat window ── */
.chat-window {
--sent-color: #0b93f6;
--received-color: #3a3a3c;
--bg: oklch(94% 0.028 298.626);
display: flex;
flex-direction: column;
padding: 0 0.5rem 0;
gap: 0.6rem;
}
@media (prefers-color-scheme: dark) {
.chat-window {
--bg: oklch(22% 0.025 298.626);
}
}
/* ── Bubble wrappers ── */
.bubble-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.received-wrapper {
align-items: flex-start;
}
/* ── Shared bubble base ── */
.bubble {
position: relative;
max-width: 255px;
margin-bottom: 0;
padding: 10px 20px;
line-height: 1.3;
word-wrap: break-word;
border-radius: 25px;
text-align: left;
white-space: pre-wrap;
font-size: 1rem;
cursor: pointer;
transition:
filter 80ms ease,
transform 80ms ease;
user-select: none;
}
/* ── Sent bubble (share text preview) ── */
.bubble-sent {
color: white;
background: var(--sent-color);
transform: rotate(-2deg);
}
.bubble-sent:hover {
background-color: #2ea8ff;
transform: rotate(-2deg) translateY(-2px);
}
.bubble-sent:hover::before {
background-color: #2ea8ff;
}
.bubble-sent:active {
background-color: #0878d4;
transform: rotate(-2deg) scale(0.97);
}
.bubble-sent:active::before {
background-color: #0878d4;
}
/* Sent tail: bottom-right */
.bubble-sent::before,
.bubble-sent::after {
position: absolute;
bottom: 0;
height: 25px;
content: "";
}
.bubble-sent::before {
width: 20px;
right: -7px;
background-color: var(--sent-color);
border-bottom-left-radius: 16px 14px;
}
.bubble-sent::after {
width: 26px;
right: -26px;
border-bottom-left-radius: 10px;
background-color: var(--bg);
}
/* ── Received bubble (action button) ── */
.bubble-received {
color: #f5f5f7;
background: var(--received-color);
transform: rotate(2deg);
padding: 14px 24px;
font-size: 1.1rem;
font-weight: 700;
min-width: 14rem;
text-align: center;
}
.bubble-received:hover {
background-color: #4a4a4e;
transform: rotate(2deg) translateY(-2px);
}
.bubble-received:hover::before {
background-color: #4a4a4e;
}
.bubble-received:active {
background-color: #2a2a2c;
transform: rotate(2deg) scale(0.97);
}
.bubble-received:active::before {
background-color: #2a2a2c;
}
.bubble-received.success {
background: #c7f7d4;
color: #155724;
}
/* Received tail: bottom-left (mirror of sent) */
.bubble-received::before,
.bubble-received::after {
position: absolute;
bottom: 0;
height: 25px;
content: "";
}
.bubble-received::before {
width: 20px;
left: -7px;
background-color: var(--received-color);
border-bottom-right-radius: 16px 14px;
}
.bubble-received::after {
width: 26px;
left: -26px;
border-bottom-right-radius: 10px;
background-color: var(--bg);
}
.bubble-received.success::before {
background-color: #c7f7d4;
}
/* ── Copy hints ── */
.copy-hint {
font-size: 0.68rem;
color: #444;
font-weight: 400;
letter-spacing: 0.01em;
padding-right: 32px;
transform: rotate(-2deg);
transform-origin: right center;
margin-top: -6px;
}
@media (prefers-color-scheme: dark) {
.copy-hint {
color: #aaa;
}
}
/* ── Snippet toggle row ── */
.snippet-toggle-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 0.25rem;
}
.snippet-label {
font-size: 0.72rem;
color: #666;
letter-spacing: 0.01em;
user-select: none;
}
@media (prefers-color-scheme: dark) {
.snippet-label {
color: #999;
}
}
.snippet-toggle {
position: relative;
width: 36px;
height: 20px;
border-radius: 10px;
background: #ccc;
border: none;
cursor: pointer;
transition: background 200ms ease;
flex-shrink: 0;
padding: 0;
}
.snippet-toggle.on {
background: #34c759;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 200ms ease;
}
.snippet-toggle.on .toggle-thumb {
transform: translateX(16px);
}
</style>

View File

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

View File

@@ -0,0 +1,144 @@
import { encodeBase64url } from '@oslojs/encoding';
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
export function getAppleAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: Bun.env.APPLE_ID!,
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/apple/callback`,
response_type: 'code',
response_mode: 'form_post',
scope: 'name email',
state
});
return `${APPLE_AUTH_URL}?${params.toString()}`;
}
export async function generateAppleClientSecret(): Promise<string> {
const header = { alg: 'ES256', kid: Bun.env.APPLE_KEY_ID! };
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: Bun.env.APPLE_TEAM_ID!,
iat: now,
exp: now + 86400 * 180,
aud: 'https://appleid.apple.com',
sub: Bun.env.APPLE_ID!
};
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
const signingInput = `${encodedHeader}.${encodedPayload}`;
// Import PEM private key
const pemBody = Bun.env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
.replace(/-----END PRIVATE KEY-----/, '')
.replace(/\s/g, '');
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey(
'pkcs8',
keyBuffer,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign']
);
const signatureBuffer = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
new TextEncoder().encode(signingInput)
);
const signature = new Uint8Array(signatureBuffer);
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
const encodedSignature = encodeBase64url(rawSignature);
return `${signingInput}.${encodedSignature}`;
}
/**
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
*/
function derToRaw(der: Uint8Array): Uint8Array {
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
let offset = 2; // skip 0x30 and total length
// Read r
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
offset++;
const rLen = der[offset];
offset++;
let r = der.slice(offset, offset + rLen);
offset += rLen;
// Read s
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
offset++;
const sLen = der[offset];
offset++;
let s = der.slice(offset, offset + sLen);
// Remove leading zero padding (DER uses it for positive sign)
if (r.length === 33 && r[0] === 0) r = r.slice(1);
if (s.length === 33 && s[0] === 0) s = s.slice(1);
// Pad to 32 bytes each
const raw = new Uint8Array(64);
raw.set(r, 32 - r.length);
raw.set(s, 64 - s.length);
return raw;
}
export async function exchangeAppleCode(
code: string,
redirectUri: string
): Promise<{
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
id_token: string;
}> {
const clientSecret = await generateAppleClientSecret();
const params = new URLSearchParams({
client_id: Bun.env.APPLE_ID!,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri
});
const response = await fetch(APPLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Apple token exchange failed: ${error}`);
}
return await response.json();
}
/**
* Decode Apple's id_token JWT payload without signature verification.
* Safe because the token is received directly from Apple's token endpoint over TLS.
*/
export function decodeAppleIdToken(idToken: string): {
sub: string;
email?: string;
email_verified?: string;
is_private_email?: string;
} {
const [, payloadB64] = idToken.split('.');
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
return payload;
}

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

@@ -0,0 +1,116 @@
import type { RequestEvent } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { testDb as db } from '$lib/server/db/test';
import * as table from '$lib/server/db/schema';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: table.Session = {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
};
await db.insert(table.session).values(session);
return session;
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.user.id, email: table.user.email },
session: table.session
})
.from(table.session)
.innerJoin(table.user, eq(table.session.userId, table.user.id))
.where(eq(table.session.id, sessionId));
if (!result) {
return { session: null, user: null };
}
const { session, user } = result;
const sessionExpired = Date.now() >= session.expiresAt.getTime();
if (sessionExpired) {
await db.delete(table.session).where(eq(table.session.id, session.id));
return { session: null, user: null };
}
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
if (renewSession) {
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
await db
.update(table.session)
.set({ expiresAt: session.expiresAt })
.where(eq(table.session.id, session.id));
}
return { session, user };
}
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
export async function invalidateSession(sessionId: string) {
await db.delete(table.session).where(eq(table.session.id, sessionId));
}
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, {
expires: expiresAt,
path: '/'
});
}
export function deleteSessionTokenCookie(event: RequestEvent) {
event.cookies.delete(sessionCookieName, {
path: '/'
});
}
export async function hashPassword(password: string): Promise<string> {
return await Bun.password.hash(password, {
algorithm: 'argon2id',
memoryCost: 4,
timeCost: 3
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await Bun.password.verify(password, hash);
} catch {
return false;
}
}
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
const user: table.User = {
id: anonymousId, // Use anonymousId as the user ID to preserve stats
email,
passwordHash,
appleId: null,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
};
await db.insert(table.user).values(user);
return user;
}
export async function getUserByEmail(email: string) {
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
return user || null;
}

View File

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

View File

@@ -0,0 +1,33 @@
import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm';
import { fetchRandomVerse } from '$lib/server/bible-api';
import type { DailyVerse } from '$lib/server/db/schema';
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
// Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
throw new Error('Invalid date format');
}
// If there's an existing verse for this date, return it
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
if (existing.length > 0) {
return existing[0];
}
// Otherwise get a new random verse for this date
const apiVerse = await fetchRandomVerse();
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
const newVerse: Omit<DailyVerse, 'createdAt'> = {
id: Bun.randomUUIDv7(),
date: dateStr,
bookId: apiVerse.bookId,
verseText: apiVerse.verseText,
reference: apiVerse.reference,
};
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
return inserted;
}

View File

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

View File

@@ -1,8 +1,14 @@
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
firstName: text('first_name'),
lastName: text('last_name'),
email: text('email').unique(),
passwordHash: text('password_hash'),
appleId: text('apple_id').unique(),
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
});
export const session = sqliteTable('session', {
id: text('id').primaryKey(),
@@ -30,11 +36,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
anonymousId: text('anonymous_id').notNull(),
date: text('date').notNull(),
guessCount: integer('guess_count').notNull(),
guesses: text('guesses'), // nullable; only stored for logged-in users
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
uniqueCompletion: unique().on(table.anonymousId, table.date),
dateIndex: index('date_idx').on(table.date),
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
}));
}, (table) => [
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
index('date_idx').on(table.date),
index('date_guess_idx').on(table.date, table.guessCount),
// Ensures schema matches the database migration and prevents duplicate submissions
unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
]);
export type DailyCompletion = typeof dailyCompletions.$inferSelect;

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;
}
/**
* 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
*/

View File

@@ -0,0 +1,186 @@
import { browser } from "$app/environment";
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
// Used to attribute stats to a player who hasn't signed in.
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
// Reactive store that keeps in-memory game state in sync with localStorage.
// Accepts getter functions (rather than plain values) so Svelte's reactivity
// system can track dependencies and re-run effects when they change.
type AuthUser = {
id: string;
firstName?: string | null;
lastName?: string | null;
email?: string | null;
};
export function createGamePersistence(
getDate: () => string,
getReference: () => string,
getCorrectBookId: () => string,
getUser: () => AuthUser | null | undefined,
) {
let guesses = $state<Guess[]>([]);
let anonymousId = $state("");
let statsSubmitted = $state(false);
let chapterGuessCompleted = $state(false);
let chapterCorrect = $state(false);
// On mount (and if the user logs in/out), resolve the player's identity and
// restore per-day flags from localStorage.
$effect(() => {
if (!browser) return;
const user = getUser();
// CRITICAL: If user is logged in, ALWAYS use their user ID
if (user) {
anonymousId = user.id;
} else {
anonymousId = getOrCreateAnonymousId();
}
// Tell analytics which player this is so events are grouped correctly.
if ((window as any).umami) {
(window as any).umami.identify(anonymousId);
}
if (user) {
const nameParts = [user.firstName, user.lastName].filter(Boolean);
(window as any).rybbit?.identify(user.id, {
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
...(user.email ? { email: user.email } : {}),
});
} else {
(window as any).rybbit?.identify(anonymousId);
}
const date = getDate();
const reference = getReference();
// Restore whether today's completion was already submitted to the server.
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
// Restore the chapter bonus guess result. The stored value includes the
// chapter the player selected, so we can re-derive whether it was correct.
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
if (chapterGuessCompleted) {
const saved = localStorage.getItem(chapterGuessKey);
if (saved) {
const data = JSON.parse(saved);
const match = reference.match(/\s(\d+):/);
const correctChapter = match ? parseInt(match[1], 10) : 1;
chapterCorrect = data.selectedChapter === correctChapter;
}
}
});
// On mount (and if the date or correct answer changes), load today's guesses
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
// each stored book ID against the correct answer.
$effect(() => {
if (!browser) return;
const date = getDate();
const correctBookId = getCorrectBookId();
const key = `bibdle-guesses-${date}`;
const saved = localStorage.getItem(key);
if (!saved) {
guesses = [];
return;
}
let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
guesses = savedIds
.map((bookId) => evaluateGuess(bookId, correctBookId))
.filter((g): g is Guess => g !== null);
});
// Persist guesses to localStorage whenever they change. Only the book IDs are
// stored — the full Guess shape is re-derived on load (see effect above).
$effect(() => {
if (!browser) return;
const date = getDate();
localStorage.setItem(
`bibdle-guesses-${date}`,
JSON.stringify(guesses.map((g) => g.book.id)),
);
});
// Called after stats are successfully submitted to the server so that
// returning to the page doesn't trigger a duplicate submission.
function markStatsSubmitted() {
if (!browser) return;
statsSubmitted = true;
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
}
// Marks the win as tracked for analytics. Returns true the first time (new
// win), false on subsequent calls so the analytics event fires exactly once.
function markWinTracked() {
if (!browser) return;
const key = `bibdle-win-tracked-${getDate()}`;
if (localStorage.getItem(key) === "true") return false;
localStorage.setItem(key, "true");
return true;
}
// Returns true if the win has already been tracked in a previous render/session.
// Used to skip the animation delay when returning to an already-won game.
function isWinAlreadyTracked(): boolean {
if (!browser) return false;
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
}
// Overwrites local state with the server's authoritative guess record.
// Called when a logged-in user opens the game on a new device so their
// progress from another device is restored.
function hydrateFromServer(guessIds: string[]) {
if (!browser) return;
const correctBookId = getCorrectBookId();
const date = getDate();
guesses = guessIds
.map((bookId) => evaluateGuess(bookId, correctBookId))
.filter((g): g is Guess => g !== null);
}
// Called by the WinScreen after the player submits their chapter bonus guess.
// Reads the result written to localStorage by WinScreen and updates reactive state.
function onChapterGuessCompleted() {
if (!browser) return;
chapterGuessCompleted = true;
const reference = getReference();
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
const saved = localStorage.getItem(chapterGuessKey);
if (saved) {
const data = JSON.parse(saved);
const match = reference.match(/\s(\d+):/);
const correctChapter = match ? parseInt(match[1], 10) : 1;
chapterCorrect = data.selectedChapter === correctChapter;
}
}
return {
get guesses() { return guesses; },
set guesses(v: Guess[]) { guesses = v; },
get anonymousId() { return anonymousId; },
get statsSubmitted() { return statsSubmitted; },
get chapterGuessCompleted() { return chapterGuessCompleted; },
get chapterCorrect() { return chapterCorrect; },
markStatsSubmitted,
markWinTracked,
isWinAlreadyTracked,
onChapterGuessCompleted,
hydrateFromServer,
};
}

View File

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

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

@@ -0,0 +1,98 @@
import type { Guess } from './game';
export function getVerseSnippet(verseText: string): string {
const words = verseText.trim().split(/\s+/);
const slice = words.slice(0, 25);
const text = slice.join(' ');
// Returns character index immediately after the Nth word (1-indexed)
function posAfterWord(n: number): number {
let pos = 0;
for (let w = 0; w < Math.min(n, slice.length); w++) {
if (w > 0) pos++; // space between words
pos += slice[w].length;
}
return pos;
}
const start = posAfterWord(9);
const end = posAfterWord(25);
// Find first punctuation mark between words 10 and 25
const range = text.substring(start, end);
const match = range.match(/[,;:.!?—–-]/);
function withClosedQuotes(snippet: string): string {
const opens = (snippet.match(/\u201C/g) ?? []).length;
const closes = (snippet.match(/\u201D/g) ?? []).length;
const closeQuote = opens > closes ? '\u201D' : '';
return `\u201C${snippet}...${closeQuote}\u201D`;
}
if (match && match.index !== undefined) {
const cutPos = start + match.index;
return withClosedQuotes(text.substring(0, cutPos).trimEnd());
}
return withClosedQuotes(text);
}
export function generateShareText(params: {
guesses: Guess[];
correctBookId: string;
dailyVerseDate: string;
chapterCorrect: boolean;
isLoggedIn: boolean;
streak?: number;
origin: string;
verseText: string;
}): string {
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
const emojis = guesses
.slice()
.reverse()
.map((guess) => {
if (guess.book.id === correctBookId) return "✅";
if (guess.adjacent) return "‼️";
if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧";
return "🟥";
})
.join("");
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const formattedDate = dateFormatter.format(
new Date(`${dailyVerseDate}T00:00:00`),
);
const bookEmoji = isLoggedIn ? "📜" : "📖";
const guessWord = guesses.length === 1 ? "guess" : "guesses";
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
const lines = [
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
`${guesses.length} ${guessWord},${streakPart}`,
`${emojis}${chapterStar}`
];
return lines.join("\n");
}
export async function shareResult(shareText: string): Promise<void> {
if ("share" in navigator) {
await (navigator as any).share({ text: shareText });
} else {
await (navigator as any).clipboard.writeText(shareText);
}
}
export async function copyToClipboard(shareText: string): Promise<void> {
await (navigator as any).clipboard.writeText(shareText);
}

View File

@@ -0,0 +1,68 @@
export interface StatsData {
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
tiedCount: number;
percentile: number;
guesses?: string[]; // Present when fetching an existing completion (cross-device sync)
}
export async function submitCompletion(params: {
anonymousId: string;
date: string;
guessCount: number;
guesses: string[];
}): Promise<StatsData | null> {
try {
const response = await fetch("/api/submit-completion", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const result = await response.json();
if (result.success && result.stats) {
return result.stats;
}
if (response.status === 409) {
// Already submitted from another device — fetch existing stats
return fetchExistingStats({ anonymousId: params.anonymousId, date: params.date });
}
if (result.error) {
console.error("Stats server error:", result.error);
}
return null;
} catch (err) {
console.error("Stats submission failed:", err);
return null;
}
}
export async function fetchExistingStats(params: {
anonymousId: string;
date: string;
}): Promise<StatsData | null> {
try {
const response = await fetch(
`/api/stats?anonymousId=${params.anonymousId}&date=${params.date}`,
);
const result = await response.json();
if (result.success && result.stats) {
return result.stats;
}
if (result.error) {
console.error("Stats server error:", result.error);
}
return null;
} catch (err) {
console.error("Stats fetch failed:", err);
return null;
}
}

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!";
}
}

15
src/lib/utils/streak.ts Normal file
View File

@@ -0,0 +1,15 @@
export async function fetchStreak(anonymousId: string, localDate: string): Promise<number> {
const params = new URLSearchParams({ anonymousId, localDate });
const res = await fetch(`/api/streak?${params}`);
if (!res.ok) return 0;
const data = await res.json();
return typeof data.streak === 'number' ? data.streak : 0;
}
export async function fetchStreakPercentile(streak: number, localDate: string): Promise<number | null> {
const params = new URLSearchParams({ streak: String(streak), localDate });
const res = await fetch(`/api/streak-percentile?${params}`);
if (!res.ok) return null;
const data = await res.json();
return typeof data.percentile === 'number' ? data.percentile : null;
}

View File

@@ -1,17 +1,26 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import "./layout.css";
import favicon from "$lib/assets/favicon.ico";
onMount(() => {
if (browser) {
const script = document.createElement('script');
script.defer = true;
script.src = 'https://umami.snail.city/script.js';
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
document.body.appendChild(script);
}
});
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<script
defer
src="https://umami.snail.city/script.js"
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
data-domains="bibdle.com,www.bibdle.com"
></script>
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
</svelte:head>
{@render children()}

View File

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

View File

@@ -1,442 +1,441 @@
<script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible";
import type { PageProps } from "./$types";
import { browser } from "$app/environment";
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
import { browser } from "$app/environment";
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte";
import GuessesTable from "$lib/components/GuessesTable.svelte";
import WinScreen from "$lib/components/WinScreen.svelte";
import Credits from "$lib/components/Credits.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte";
import GuessesTable from "$lib/components/GuessesTable.svelte";
import CountdownTimer from "$lib/components/CountdownTimer.svelte";
import WinScreen from "$lib/components/WinScreen.svelte";
import Feedback from "$lib/components/Feedback.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import { getGrade } from "$lib/utils/game";
import { evaluateGuess } from "$lib/utils/game";
import {
generateShareText,
shareResult,
copyToClipboard as clipboardCopy,
} from "$lib/utils/share";
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
import {
submitCompletion,
fetchExistingStats,
type StatsData,
} from "$lib/utils/stats-client";
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
import { SvelteSet } from "svelte/reactivity";
interface Guess {
book: BibleBook;
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
}
let { data }: PageProps = $props();
let { data }: PageProps = $props();
let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId);
let correctBook = $derived(data.correctBook);
let user = $derived(data.user);
let session = $derived(data.session);
let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId);
let searchQuery = $state("");
let copied = $state(false);
let isDev = $state(false);
let authModalOpen = $state(false);
let showWinScreen = $state(false);
let statsData = $state<StatsData | null>(null);
let streak = $state(0);
let streakPercentile = $state<number | null>(null);
let guesses = $state<Guess[]>([]);
const persistence = createGamePersistence(
() => dailyVerse.date,
() => dailyVerse.reference,
() => correctBookId,
() => user,
);
let searchQuery = $state("");
let guessedIds = $derived(
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
);
let copied = $state(false);
let isDev = $state(false);
const currentDate = $derived(
new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
);
let anonymousId = $state("");
let statsSubmitted = $state(false);
let statsData = $state<{
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
} | null>(null);
let isWon = $derived(
persistence.guesses.some((g) => g.book.id === correctBookId),
);
let blurChapter = $derived(
isWon &&
persistence.guesses.length === 1 &&
!persistence.chapterGuessCompleted,
);
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
async function submitGuess(bookId: string) {
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
const currentDate = $derived(
new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})
);
const guess = evaluateGuess(bookId, correctBookId);
if (!guess) return;
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
let grade = $derived(
isWon
? getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0)
: ""
);
if (persistence.guesses.length === 0) {
const key = `bibdle-first-guess-${dailyVerse.date}`;
if (
browser &&
localStorage.getItem(key) !== "true" &&
(window as any).umami
) {
(window as any).umami.track("First guess");
(window as any).rybbit?.event("First guess");
localStorage.setItem(key, "true");
}
}
function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id);
}
persistence.guesses = [guess, ...persistence.guesses];
searchQuery = "";
function isAdjacent(id1: string, id2: string): boolean {
const b1 = getBookById(id1);
const b2 = getBookById(id2);
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
}
if (
guess.book.id === correctBookId &&
browser &&
persistence.anonymousId
) {
statsData = await submitCompletion({
anonymousId: persistence.anonymousId,
date: dailyVerse.date,
guessCount: persistence.guesses.length,
guesses: persistence.guesses.map((g) => g.book.id),
});
if (statsData) {
persistence.markStatsSubmitted();
}
}
}
function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return;
// Reload when the user returns to a stale tab on a new calendar day
$effect(() => {
if (!browser) return;
const book = getBookById(bookId);
if (!book) return;
const loadedDate = new Date().toLocaleDateString("en-CA");
const correctBook = getBookById(correctBookId);
if (!correctBook) return;
function onVisibilityChange() {
if (document.hidden) return;
const now = new Date().toLocaleDateString("en-CA");
if (now !== loadedDate) {
window.location.reload();
}
}
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(book.id, correctBookId);
document.addEventListener("visibilitychange", onVisibilityChange);
return () =>
document.removeEventListener(
"visibilitychange",
onVisibilityChange,
);
});
console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`
);
$effect(() => {
if (!browser) return;
isDev =
window.location.host === "localhost:5173" ||
window.location.host === "test.bibdle.com";
});
if (guesses.length === 0) {
const key = `bibdle-first-guess-${dailyVerse.date}`;
if (
localStorage.getItem(key) !== "true" &&
browser &&
(window as any).umami
) {
(window as any).umami.track("First guess");
localStorage.setItem(key, "true");
}
}
// Fetch stats on page load if user already won in a previous session (same device)
$effect(() => {
if (
!browser ||
!isWon ||
!persistence.anonymousId ||
statsData ||
!persistence.statsSubmitted
)
return;
fetchExistingStats({
anonymousId: persistence.anonymousId,
date: dailyVerse.date,
}).then((data) => {
statsData = data;
});
});
guesses = [
{
book,
testamentMatch,
sectionMatch,
adjacent,
},
...guesses,
];
// For logged-in users on a new device: restore today's game state from the server.
// Runs even when isWon is true so that logging in after completing the game on another
// device always replaces local localStorage with the authoritative DB record.
let crossDeviceCheckDate = $state<string | null>(null);
$effect(() => {
if (
!browser ||
!user ||
!dailyVerse?.date ||
crossDeviceCheckDate === dailyVerse.date ||
!persistence.anonymousId
)
return;
crossDeviceCheckDate = dailyVerse.date;
fetchExistingStats({
anonymousId: persistence.anonymousId,
date: dailyVerse.date,
}).then((data) => {
if (data?.guesses?.length) {
persistence.hydrateFromServer(data.guesses);
statsData = data;
persistence.markStatsSubmitted();
}
});
});
searchQuery = "";
}
// Delay showing win screen until GuessesTable animation completes
$effect(() => {
if (!isWon) {
showWinScreen = false;
return;
}
function generateUUID(): string {
// Try native randomUUID if available
if (typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
if (persistence.isWinAlreadyTracked()) {
showWinScreen = true;
} else {
const animationDelay = 1800;
const timeoutId = setTimeout(() => {
showWinScreen = true;
}, animationDelay);
return () => clearTimeout(timeoutId);
}
});
// Fallback UUID v4 generator for older browsers
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// Track win analytics
$effect(() => {
if (!browser || !isWon) return;
const isNew = persistence.markWinTracked();
if (isNew && (window as any).umami) {
(window as any).umami.track("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
(window as any).rybbit?.event("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
}
});
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
// Fetch streak when the player wins
$effect(() => {
if (!browser || !isWon || !persistence.anonymousId) return;
const localDate = new Date().toLocaleDateString("en-CA");
fetchStreak(persistence.anonymousId, localDate).then((result) => {
streak = result;
if (result >= 2) {
fetchStreakPercentile(result, localDate).then((p) => {
streakPercentile = p;
});
}
});
});
// Initialize anonymous ID
$effect(() => {
if (!browser) return;
anonymousId = getOrCreateAnonymousId();
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
statsSubmitted = localStorage.getItem(statsKey) === "true";
});
function getShareText(): string {
return generateShareText({
guesses: persistence.guesses,
correctBookId,
dailyVerseDate: dailyVerse.date,
chapterCorrect: persistence.chapterCorrect,
isLoggedIn: !!user,
streak,
origin: window.location.origin,
verseText: dailyVerse.verseText,
});
}
$effect(() => {
if (!browser) return;
isDev = window.location.host === "localhost:5173";
});
function handleShare() {
if (copied || !browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
shareResult(getShareText())
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
// Load saved guesses
$effect(() => {
if (!browser) return;
const key = `bibdle-guesses-${dailyVerse.date}`;
const saved = localStorage.getItem(key);
if (saved) {
let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds));
guesses = savedIds.map((bookId: string) => {
const book = getBookById(bookId)!;
const correctBook = getBookById(correctBookId)!;
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(bookId, correctBookId);
return {
book,
testamentMatch,
sectionMatch,
adjacent,
};
});
}
});
$effect(() => {
if (!browser) return;
localStorage.setItem(
`bibdle-guesses-${dailyVerse.date}`,
JSON.stringify(guesses.map((g) => g.book.id))
);
});
// Auto-submit stats when user wins
$effect(() => {
console.log("Stats effect triggered:", {
browser,
isWon,
anonymousId,
statsSubmitted,
statsData,
});
if (!browser || !isWon || !anonymousId) {
console.log("Basic conditions not met");
return;
}
if (statsSubmitted && !statsData) {
console.log("Fetching existing stats...");
(async () => {
try {
const response = await fetch(
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`
);
const result = await response.json();
console.log("Stats response:", result);
if (result.success && result.stats) {
console.log("Setting stats data:", result.stats);
statsData = result.stats;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true"
);
} else if (result.error) {
console.error("Server error:", result.error);
} else {
console.error("Unexpected response format:", result);
}
} catch (err) {
console.error("Stats fetch failed:", err);
}
})();
return;
}
console.log("Submitting stats...");
async function submitStats() {
try {
const payload = {
anonymousId,
date: dailyVerse.date,
guessCount: guesses.length,
};
console.log("Sending POST request with:", payload);
const response = await fetch("/api/submit-completion", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const result = await response.json();
console.log("Stats response:", result);
if (result.success && result.stats) {
console.log("Setting stats data:", result.stats);
statsData = result.stats;
statsSubmitted = true;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true"
);
} else if (result.error) {
console.error("Server error:", result.error);
} else {
console.error("Unexpected response format:", result);
}
} catch (err) {
console.error("Stats submission failed:", err);
}
}
submitStats();
});
$effect(() => {
if (!browser || !isWon) return;
const key = `bibdle-win-tracked-${dailyVerse.date}`;
if (localStorage.getItem(key) === "true") return;
if ((window as any).umami) {
(window as any).umami.track("Guessed correctly", {
totalGuesses: guesses.length,
});
}
localStorage.setItem(key, "true");
});
function generateShareText(): string {
const emojis = guesses
.slice()
.reverse()
.map((guess) => {
if (guess.book.id === correctBookId) return "✅";
if (guess.adjacent) return "‼️";
if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧";
return "🟥";
})
.join("");
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const formattedDate = dateFormatter.format(
new Date(`${dailyVerse.date}T00:00:00`)
);
const siteUrl = window.location.origin;
return [
`📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} ${guesses.length == 1 ? "guess" : "guesses"})`,
`${emojis}`,
siteUrl,
].join("\n");
}
async function share() {
if (!browser) return;
const shareText = generateShareText();
try {
if ("share" in navigator) {
await (navigator as any).share({ text: shareText });
} else {
await (navigator as any).clipboard.writeText(shareText);
}
} catch (err) {
console.error("Share failed:", err);
throw err;
}
}
async function copyToClipboard() {
if (!browser) return;
const shareText = generateShareText();
try {
await (navigator as any).clipboard.writeText(shareText);
copied = true;
setTimeout(() => {
copied = false;
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
throw err;
}
}
function handleShare() {
if (copied || !browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
share()
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
function clearLocalStorage() {
if (!browser) return;
// Clear all bibdle-related localStorage items
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("bibdle-")) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Reload the page to reset state
window.location.reload();
}
async function handleCopyToClipboard() {
if (!browser) return;
try {
await clipboardCopy(getShareText());
copied = true;
setTimeout(() => {
copied = false;
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
}
}
</script>
<svelte:head>
<!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
<!-- <meta
name="description"
content="Guess which book of the Bible a verse comes from."
/> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
</svelte:head>
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
<div class="w-full max-w-3xl mx-auto px-4">
<h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
>
<TitleAnimation />
<div class="font-normal"></div>
</h1>
<div class="text-center mb-8">
<span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
>
</div>
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950 py-8">
<div class="w-full max-w-3xl mx-auto px-4">
<h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 animate-fade-in-up"
>
<TitleAnimation />
<div class="font-normal"></div>
</h1>
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
<span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
>
</div>
<VerseDisplay {data} {isWon} />
<div class="flex flex-col gap-6">
<div class="animate-fade-in-up animate-delay-200">
<VerseDisplay {data} {isWon} {blurChapter} />
</div>
{#if !isWon}
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
{:else}
<WinScreen
{grade}
{statsData}
{correctBookId}
{handleShare}
{copyToClipboard}
bind:copied
{statsSubmitted}
guessCount={guesses.length}
/>
<CountdownTimer />
{/if}
{#if !isWon}
<div class="animate-fade-in-up animate-delay-400">
<SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
</div>
{:else if showWinScreen}
<div class="animate-fade-in-up animate-delay-400">
<WinScreen
{statsData}
{correctBookId}
{handleShare}
copyToClipboard={handleCopyToClipboard}
bind:copied
statsSubmitted={persistence.statsSubmitted}
guessCount={persistence.guesses.length}
reference={dailyVerse.reference}
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
shareText={getShareText()}
verseText={dailyVerse.verseText}
{streak}
{streakPercentile}
/>
</div>
{/if}
<GuessesTable {guesses} {correctBookId} />
{#if isWon}
<Feedback />
{/if}
{#if isDev}
<button
onclick={clearLocalStorage}
class="mt-4 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-bold transition-colors"
>
Clear LocalStorage
</button>
{/if}
</div>
<div class="animate-fade-in-up animate-delay-600">
<GuessesTable guesses={persistence.guesses} {correctBookId} />
</div>
{#if isWon}
<div class="animate-fade-in-up animate-delay-800">
<Credits />
</div>
{/if}
</div>
{#if isDev}
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
<div class="flex flex-col md:flex-row gap-3">
<a
href="/stats?{user
? `userId=${user.id}`
: `anonymousId=${persistence.anonymousId}`}&tz={encodeURIComponent(
Intl.DateTimeFormat().resolvedOptions().timeZone,
)}"
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
>
📊 View Stats
</a>
{#if user}
<form
method="POST"
action="/auth/logout"
use:enhance
class="w-full md:w-auto"
>
<button
type="submit"
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
>
🚪 Sign Out
</button>
</form>
{:else}
<button
onclick={() => (authModalOpen = true)}
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
>
🔐 Sign In
</button>
{/if}
</div>
<div
class="text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded border dark:border-gray-700"
>
<div><strong>Debug Info:</strong></div>
<div>
User: {user
? `${user.email} (ID: ${user.id})`
: "Not signed in"}
</div>
<div>
Session: {session
? `Expires ${session.expiresAt.toLocaleDateString()}`
: "No session"}
</div>
<div>
Anonymous ID: {persistence.anonymousId || "Not set"}
</div>
<div>
Client Local Time: {new Date().toLocaleString("en-US", {
timeZone:
Intl.DateTimeFormat().resolvedOptions()
.timeZone,
timeZoneName: "short",
})}
</div>
<div>
Client Local Date: {new Date().toLocaleDateString(
"en-CA",
)}
</div>
<div>Daily Verse Date: {dailyVerse.date}</div>
<div>Streak: {streak}</div>
</div>
<DevButtons anonymousId={persistence.anonymousId} />
</div>
{/if}
{#if user && session}
<div
class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center text-xs text-gray-400 dark:text-gray-500"
>
Signed in as {[user.firstName, user.lastName]
.filter(Boolean)
.join(" ")}{user.email
? ` (${user.email})`
: ""}{user.appleId ? " using Apple" : ""} |
<form
method="POST"
action="/auth/logout"
use:enhance
class="inline"
>
<button
type="submit"
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
>Sign out</button
>
</form>
</div>
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} anonymousId={persistence.anonymousId} />

23
src/routes/+page.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { PageLoad } from './$types';
// Disable SSR so the load function runs on the client with the correct local date
export const ssr = false;
export const load: PageLoad = async ({ fetch, data }) => {
const localDate = new Date().toLocaleDateString("en-CA");
const res = await fetch('/api/daily-verse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: localDate }),
});
const result = await res.json();
return {
...data,
dailyVerse: result.dailyVerse,
correctBookId: result.correctBookId,
correctBook: result.correctBook,
};
};

View File

@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVerseForDate } from '$lib/server/daily-verse';
import { getBookById } from '$lib/server/bible';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const { date } = body;
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
}
const dateStr = date;
const dailyVerse = await getVerseForDate(dateStr);
const correctBook = getBookById(dailyVerse.bookId) ?? null;
return json({
dailyVerse,
correctBookId: dailyVerse.bookId,
correctBook,
});
};

View File

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

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

@@ -0,0 +1,63 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { and, eq, asc } from 'drizzle-orm';
import { json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
try {
const anonymousId = url.searchParams.get('anonymousId');
const date = url.searchParams.get('date');
if (!anonymousId || !date) {
return json({ error: 'Invalid data' }, { status: 400 });
}
const userCompletions = await db
.select()
.from(dailyCompletions)
.where(and(
eq(dailyCompletions.anonymousId, anonymousId),
eq(dailyCompletions.date, date)
))
.limit(1);
if (userCompletions.length === 0) {
return json({ error: 'No completion found' }, { status: 404 });
}
const userCompletion = userCompletions[0];
const guessCount = userCompletion.guessCount;
const allCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.date, date))
.orderBy(asc(dailyCompletions.completedAt));
const totalSolves = allCompletions.length;
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1;
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
const guesses = userCompletion.guesses ? JSON.parse(userCompletion.guesses) : undefined;
return json({
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile, guesses }
});
} catch (err) {
console.error('Error fetching stats:', err);
return json({ error: 'Failed to fetch stats' }, { status: 500 });
}
};

View File

@@ -0,0 +1,103 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const streakParam = url.searchParams.get('streak');
const localDate = url.searchParams.get('localDate');
if (!streakParam || !localDate) {
error(400, 'Missing streak or localDate');
}
const targetStreak = parseInt(streakParam, 10);
if (isNaN(targetStreak) || targetStreak < 1) {
error(400, 'Invalid streak');
}
// Fetch all completions ordered by anonymous_id and date desc
// so we can walk each user's history to compute their current streak.
const rows = await db
.select({
anonymousId: dailyCompletions.anonymousId,
date: dailyCompletions.date,
})
.from(dailyCompletions)
.orderBy(desc(dailyCompletions.date));
// Group dates by user
const byUser = new Map<string, string[]>();
for (const row of rows) {
const list = byUser.get(row.anonymousId);
if (list) {
list.push(row.date);
} else {
byUser.set(row.anonymousId, [row.date]);
}
}
// Calculate the current streak for each user.
// Start from today; if the user hasn't played today yet, try yesterday so
// that streaks aren't zeroed out mid-day before the player has had a chance
// to complete today's puzzle.
const yesterday = new Date(`${localDate}T00:00:00`);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toLocaleDateString('en-CA');
const thirtyDaysAgo = new Date(`${localDate}T00:00:00`);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toLocaleDateString('en-CA');
// For each user, compute their current streak and whether they've played
// within the last 30 days. "Eligible players" = active streak OR recent play.
const userStats: { streak: number; isEligible: boolean }[] = [];
for (const [, dates] of byUser) {
// dates are already desc-sorted
const dateSet = new Set(dates);
// Pick the most recent anchor: today if played, otherwise yesterday
const anchor = dateSet.has(localDate) ? localDate : yesterdayStr;
let streak = 0;
let cursor = new Date(`${anchor}T00:00:00`);
while (true) {
const dateStr = cursor.toLocaleDateString('en-CA');
if (!dateSet.has(dateStr)) break;
streak++;
cursor.setDate(cursor.getDate() - 1);
}
const hasRecentPlay = dates.some((d) => d >= thirtyDaysAgoStr);
userStats.push({ streak, isEligible: streak >= 1 || hasRecentPlay });
}
const eligiblePlayers = userStats.filter((u) => u.isEligible);
if (eligiblePlayers.length === 0) {
console.log('[streak-percentile] No eligible players found, returning 100th percentile');
return json({ percentile: 100 });
}
// Percentage of eligible players who have a streak >= targetStreak
const atOrAbove = eligiblePlayers.filter((u) => u.streak >= targetStreak).length;
const raw = (atOrAbove / eligiblePlayers.length) * 100;
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
console.log('[streak-percentile]', {
localDate,
targetStreak,
totalUsers: byUser.size,
totalRows: rows.length,
eligiblePlayers: eligiblePlayers.length,
activeStreaks: userStats.filter((u) => u.streak >= 1).length,
recentPlayers: userStats.filter((u) => u.isEligible).length,
atOrAbove,
raw,
percentile,
});
return json({ percentile });
};

View File

@@ -0,0 +1,36 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const anonymousId = url.searchParams.get('anonymousId');
const localDate = url.searchParams.get('localDate');
if (!anonymousId || !localDate) {
error(400, 'Missing anonymousId or localDate');
}
// Fetch all completion dates for this user, newest first
const rows = await db
.select({ date: dailyCompletions.date })
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId))
.orderBy(desc(dailyCompletions.date));
const completedDates = new Set(rows.map((r) => r.date));
// Walk backwards from localDate, counting consecutive completed days
let streak = 0;
let cursor = new Date(`${localDate}T00:00:00`);
while (true) {
const dateStr = cursor.toLocaleDateString('en-CA'); // YYYY-MM-DD
if (!completedDates.has(dateStr)) break;
streak++;
cursor.setDate(cursor.getDate() - 1);
}
return json({ streak: streak < 2 ? 0 : streak });
};

View File

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

View File

@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { getAppleAuthUrl } from '$lib/server/apple-auth';
import { encodeBase64url } from '@oslojs/encoding';
export const actions: Actions = {
default: async ({ cookies, request }) => {
const data = await request.formData();
const anonymousId = data.get('anonymousId')?.toString() || '';
// Generate CSRF state
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
const state = encodeBase64url(stateBytes);
// Store state + anonymousId in a short-lived cookie
// sameSite 'none' + secure required because Apple POSTs cross-origin
cookies.set('apple_oauth_state', JSON.stringify({ state, anonymousId }), {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'none',
maxAge: 600
});
redirect(302, getAppleAuthUrl(state));
}
};

View File

@@ -0,0 +1,137 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { exchangeAppleCode, decodeAppleIdToken } from '$lib/server/apple-auth';
import { env as publicEnv } from '$env/dynamic/public';
import * as auth from '$lib/server/auth';
import { db } from '$lib/server/db';
import { user as userTable } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const POST: RequestHandler = async ({ request, cookies }) => {
const formData = await request.formData();
const code = formData.get('code')?.toString();
const state = formData.get('state')?.toString();
// Apple sends user info as JSON string on FIRST authorization only
const userInfoStr = formData.get('user')?.toString();
// Validate CSRF state
const storedRaw = cookies.get('apple_oauth_state');
if (!storedRaw || !state || !code) {
throw error(400, 'Invalid OAuth callback');
}
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
if (stored.state !== state) {
throw error(400, 'State mismatch');
}
cookies.delete('apple_oauth_state', { path: '/' });
const anonId = stored.anonymousId;
if (!anonId) {
console.error('[Apple auth] Missing anonymousId in state cookie');
throw error(400, 'Missing anonymous ID — please return to the game and try again');
}
// Exchange authorization code for tokens
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
const claims = decodeAppleIdToken(tokens.id_token);
const appleId = claims.sub;
// Parse user info (only present on first authorization)
let appleFirstName: string | undefined;
let appleLastName: string | undefined;
if (userInfoStr) {
try {
const userInfo = JSON.parse(userInfoStr);
appleFirstName = userInfo.name?.firstName;
appleLastName = userInfo.name?.lastName;
} catch {
/* ignore parse errors */
}
}
// --- User resolution ---
let userId: string;
// 1. Check if a user with this appleId already exists (returning user)
const existingAppleUser = await auth.getUserByAppleId(appleId);
if (existingAppleUser) {
userId = existingAppleUser.id;
console.log(`[Apple auth] Returning Apple user: userId=${userId}, anonId=${anonId}`);
await auth.migrateAnonymousStats(anonId, userId);
} else if (claims.email) {
// 2. Check if email matches an existing email/password user
const existingEmailUser = await auth.getUserByEmail(claims.email);
if (existingEmailUser) {
// Link Apple account to existing user
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
userId = existingEmailUser.id;
console.log(`[Apple auth] Linked Apple to existing email user: userId=${userId}, anonId=${anonId}`);
await auth.migrateAnonymousStats(anonId, userId);
} else {
// 3. Brand new user — use anonymousId as user ID to preserve local stats
userId = anonId;
console.log(`[Apple auth] New user (has email): userId=${userId}`);
try {
await db.insert(userTable).values({
id: userId,
email: claims.email,
passwordHash: null,
appleId,
firstName: appleFirstName || null,
lastName: appleLastName || null,
isPrivate: false
});
} catch (e: any) {
// Handle race condition: if appleId was inserted between our check and insert
if (e?.message?.includes('UNIQUE constraint')) {
const retryUser = await auth.getUserByAppleId(appleId);
if (retryUser) {
userId = retryUser.id;
console.log(`[Apple auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
await auth.migrateAnonymousStats(anonId, userId);
} else {
throw error(500, 'Failed to create user');
}
} else {
throw e;
}
}
}
} else {
// No email from Apple — create account with appleId only
userId = anonId;
console.log(`[Apple auth] New user (no email): userId=${userId}`);
try {
await db.insert(userTable).values({
id: userId,
email: null,
passwordHash: null,
appleId,
firstName: appleFirstName || null,
lastName: appleLastName || null,
isPrivate: false
});
} catch (e: any) {
if (e?.message?.includes('UNIQUE constraint')) {
const retryUser = await auth.getUserByAppleId(appleId);
if (retryUser) {
userId = retryUser.id;
console.log(`[Apple auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
await auth.migrateAnonymousStats(anonId, userId);
} else {
throw error(500, 'Failed to create user');
}
} else {
throw e;
}
}
}
// Create session
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
redirect(302, '/');
};

View File

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

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { page } from "$app/state";
import { browser } from "$app/environment";
import AuthModal from "$lib/components/AuthModal.svelte";
let isOpen = $state(true);
const user = $derived(page.data.user);
let anonymousId = $state("");
$effect(() => {
if (browser) {
anonymousId = localStorage.getItem("bibdle-anonymous-id") ?? "";
}
});
</script>
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
{#if user}
<div class="text-white text-center space-y-4">
<p class="text-lg">
Signed in as <strong>{user.email ?? "no email"}</strong>
</p>
<form method="POST" action="/auth/logout">
<button
class="px-4 py-2 bg-red-600 rounded-md hover:bg-red-700 transition-colors"
>
Sign Out
</button>
</form>
</div>
{:else}
<button
onclick={() => (isOpen = true)}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Open Auth Modal
</button>
<AuthModal bind:isOpen {anonymousId} />
{/if}
</div>

View File

@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import * as auth from '$lib/server/auth';
export const actions: Actions = {
default: async ({ locals, cookies }) => {
if (locals.session) {
await auth.invalidateSession(locals.session.id);
}
auth.deleteSessionTokenCookie({ cookies });
redirect(302, '/');
}
};

View File

@@ -0,0 +1,53 @@
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import * as auth from '$lib/server/auth';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email')?.toString();
const password = data.get('password')?.toString();
const anonymousId = data.get('anonymousId')?.toString();
if (!email || !password) {
return fail(400, { error: 'Email and password are required' });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, { error: 'Please enter a valid email address' });
}
if (password.length < 6) {
return fail(400, { error: 'Password must be at least 6 characters' });
}
try {
// Get user by email
const user = await auth.getUserByEmail(email);
if (!user || !user.passwordHash) {
return fail(400, { error: 'Invalid email or password' });
}
// Verify password
const isValidPassword = await auth.verifyPassword(password, user.passwordHash);
if (!isValidPassword) {
return fail(400, { error: 'Invalid email or password' });
}
// Migrate anonymous stats if different anonymous ID
await auth.migrateAnonymousStats(anonymousId, user.id);
// Create session
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, user.id);
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
return { success: true };
} catch (error) {
console.error('Sign in error:', error);
return fail(500, { error: 'An error occurred during sign in' });
}
}
};

View File

@@ -0,0 +1,64 @@
import { redirect, fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import * as auth from '$lib/server/auth';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email')?.toString();
const password = data.get('password')?.toString();
const firstName = data.get('firstName')?.toString();
const lastName = data.get('lastName')?.toString();
const anonymousId = data.get('anonymousId')?.toString();
if (!email || !password || !anonymousId) {
return fail(400, { error: 'Email, password, and anonymous ID are required' });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, { error: 'Please enter a valid email address' });
}
if (password.length < 6) {
return fail(400, { error: 'Password must be at least 6 characters' });
}
try {
// Check if user already exists
const existingUser = await auth.getUserByEmail(email);
if (existingUser) {
return fail(400, { error: 'An account with this email already exists' });
}
// Hash password
const passwordHash = await auth.hashPassword(password);
// Create user with anonymousId as the user ID
const user = await auth.createUser(
anonymousId,
email,
passwordHash,
firstName || undefined,
lastName || undefined
);
// Create session
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, user.id);
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
return { success: true };
} catch (error) {
console.error('Sign up error:', error);
// Check if it's a unique constraint error (user with this ID already exists)
if (error instanceof Error && error.message.includes('UNIQUE constraint')) {
return fail(400, { error: 'This account is already registered. Please sign in instead.' });
}
return fail(500, { error: 'An error occurred during account creation' });
}
}
};

View File

@@ -0,0 +1,145 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm';
// Helper: Escape XML special characters
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Helper: Format YYYY-MM-DD to RFC 822 date string
function formatRFC822(dateStr: string): string {
// Parse date in America/New_York timezone (EST/EDT)
// Assuming midnight ET
const date = new Date(dateStr + 'T00:00:00-05:00');
return date.toUTCString().replace('GMT', 'EST');
}
// Helper: Format YYYY-MM-DD to readable date
function formatReadableDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'America/New_York'
});
}
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
function formatVerseText(text: string): string {
let formatted = text;
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
const pairs: [string, string][] = [
['(', ')'],
['[', ']'],
['{', '}'],
['"', '"'],
["'", "'"],
['\u201C', '\u201D'], // " "
['\u2018', '\u2019'] // ' '
];
// Check if text starts with opening punctuation without closing
for (const [open, close] of pairs) {
if (formatted.startsWith(open) && !formatted.includes(close)) {
formatted += '...' + close;
break;
}
}
// Check if text ends with closing punctuation without opening
for (const [open, close] of pairs) {
if (formatted.endsWith(close) && !formatted.includes(open)) {
formatted = open + '...' + formatted;
break;
}
}
// Check if text contains unbalanced opening quotes (not at start) without closing
for (const [open, close] of pairs) {
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
if (openCount > closeCount) {
formatted += close;
break;
}
}
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
// Replace trailing punctuation with ellipsis
// Preserve closing quotes/brackets that may have been added
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
return formatted;
}
export const GET: RequestHandler = async ({ request }) => {
try {
// Query last 30 verses, ordered by date descending
const verses = await db
.select()
.from(dailyVerses)
.orderBy(desc(dailyVerses.date))
.limit(30);
// Generate ETag based on latest verse date
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
// Check if client has cached version
if (request.headers.get('If-None-Match') === etag) {
return new Response(null, { status: 304 });
}
// Get site URL from environment or use default
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
// Build RSS XML
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
const items = verses
.map(
(verse) => `
<item>
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
<link>${SITE_URL}</link>
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
<pubDate>${formatRFC822(verse.date)}</pubDate>
</item>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Bibdle</title>
<link>${SITE_URL}</link>
<description>A daily Bible game</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>720</ttl>${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
ETag: etag
}
});
} catch (error) {
console.error('RSS feed generation error:', error);
return new Response('Internal Server Error', { status: 500 });
}
};

View File

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

View File

@@ -10,10 +10,54 @@ html, body {
background: oklch(89.126% 0.06134 298.626);
}
@media (prefers-color-scheme: dark) {
html, body {
background: oklch(18% 0.03 298.626);
}
}
.big-text {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.2em;
color: rgb(107 114 128);
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
.big-text {
color: rgb(156 163 175);
}
}
/* Page load animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.8s ease-out both;
}
.animate-delay-200 {
animation-delay: 0.2s;
}
.animate-delay-400 {
animation-delay: 0.4s;
}
.animate-delay-600 {
animation-delay: 0.6s;
}
.animate-delay-800 {
animation-delay: 0.8s;
}

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

View File

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

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

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

234
tests/bible.test.ts Normal file
View File

@@ -0,0 +1,234 @@
import { describe, test, expect } from "bun:test";
import {
getBookById,
getBookByNumber,
getBooksByTestament,
getBooksBySection,
isAdjacent,
bookNumberToId,
bookIdToNumber,
bibleBooks,
} from "$lib/server/bible";
describe("bibleBooks data integrity", () => {
test("contains exactly 66 books", () => {
expect(bibleBooks).toHaveLength(66);
});
test("order numbers are 1 through 66 with no gaps or duplicates", () => {
const orders = bibleBooks.map((b) => b.order).sort((a, b) => a - b);
for (let i = 0; i < 66; i++) {
expect(orders[i]).toBe(i + 1);
}
});
test("book IDs are unique", () => {
const ids = bibleBooks.map((b) => b.id);
const unique = new Set(ids);
expect(unique.size).toBe(66);
});
test("every book has a non-empty name", () => {
for (const book of bibleBooks) {
expect(book.name.length).toBeGreaterThan(0);
}
});
test("Old Testament has 39 books", () => {
expect(bibleBooks.filter((b) => b.testament === "old")).toHaveLength(39);
});
test("New Testament has 27 books", () => {
expect(bibleBooks.filter((b) => b.testament === "new")).toHaveLength(27);
});
test("Genesis is first and Revelation is last", () => {
const sorted = [...bibleBooks].sort((a, b) => a.order - b.order);
expect(sorted[0].id).toBe("GEN");
expect(sorted[65].id).toBe("REV");
});
test("Matthew is the first New Testament book", () => {
const nt = bibleBooks
.filter((b) => b.testament === "new")
.sort((a, b) => a.order - b.order);
expect(nt[0].id).toBe("MAT");
});
});
describe("section counts", () => {
test("Law: 5 books", () => {
expect(getBooksBySection("Law")).toHaveLength(5);
});
test("History: 13 books (12 OT + Acts)", () => {
expect(getBooksBySection("History")).toHaveLength(13);
});
test("Wisdom: 5 books", () => {
expect(getBooksBySection("Wisdom")).toHaveLength(5);
});
test("Major Prophets: 5 books", () => {
expect(getBooksBySection("Major Prophets")).toHaveLength(5);
});
test("Minor Prophets: 12 books", () => {
expect(getBooksBySection("Minor Prophets")).toHaveLength(12);
});
test("Gospels: 4 books", () => {
expect(getBooksBySection("Gospels")).toHaveLength(4);
});
test("Pauline Epistles: 13 books", () => {
expect(getBooksBySection("Pauline Epistles")).toHaveLength(13);
});
test("General Epistles: 8 books", () => {
expect(getBooksBySection("General Epistles")).toHaveLength(8);
});
test("Apocalyptic: 1 book (Revelation)", () => {
const books = getBooksBySection("Apocalyptic");
expect(books).toHaveLength(1);
expect(books[0].id).toBe("REV");
});
test("all sections sum to 66", () => {
const total =
getBooksBySection("Law").length +
getBooksBySection("History").length +
getBooksBySection("Wisdom").length +
getBooksBySection("Major Prophets").length +
getBooksBySection("Minor Prophets").length +
getBooksBySection("Gospels").length +
getBooksBySection("Pauline Epistles").length +
getBooksBySection("General Epistles").length +
getBooksBySection("Apocalyptic").length;
expect(total).toBe(66);
});
});
describe("getBookById", () => {
test("returns Genesis for GEN", () => {
const book = getBookById("GEN");
expect(book).toBeDefined();
expect(book!.name).toBe("Genesis");
});
test("returns Revelation for REV", () => {
const book = getBookById("REV");
expect(book!.name).toBe("Revelation");
});
test("returns undefined for unknown ID", () => {
expect(getBookById("UNKNOWN")).toBeUndefined();
});
});
describe("getBookByNumber", () => {
test("1 → Genesis", () => {
expect(getBookByNumber(1)!.id).toBe("GEN");
});
test("66 → Revelation", () => {
expect(getBookByNumber(66)!.id).toBe("REV");
});
test("40 → Matthew (first NT book)", () => {
expect(getBookByNumber(40)!.id).toBe("MAT");
});
test("0 → undefined", () => {
expect(getBookByNumber(0)).toBeUndefined();
});
test("67 → undefined", () => {
expect(getBookByNumber(67)).toBeUndefined();
});
});
describe("getBooksByTestament", () => {
test("old returns 39 books", () => {
expect(getBooksByTestament("old")).toHaveLength(39);
});
test("new returns 27 books", () => {
expect(getBooksByTestament("new")).toHaveLength(27);
});
test("all OT books have testament = old", () => {
for (const book of getBooksByTestament("old")) {
expect(book.testament).toBe("old");
}
});
test("all NT books have testament = new", () => {
for (const book of getBooksByTestament("new")) {
expect(book.testament).toBe("new");
}
});
});
describe("isAdjacent", () => {
test("Genesis and Exodus are adjacent", () => {
expect(isAdjacent("GEN", "EXO")).toBe(true);
});
test("adjacency is symmetric", () => {
expect(isAdjacent("EXO", "GEN")).toBe(true);
});
test("Malachi and Matthew are adjacent across testament boundary", () => {
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
});
test("Jude and Revelation are adjacent", () => {
expect(isAdjacent("JUD", "REV")).toBe(true); // 65, 66
});
test("same book is not adjacent to itself", () => {
expect(isAdjacent("GEN", "GEN")).toBe(false);
});
test("books two apart are not adjacent", () => {
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
});
test("returns false for invalid IDs", () => {
expect(isAdjacent("FAKE", "GEN")).toBe(false);
});
});
describe("bookNumberToId / bookIdToNumber lookup tables", () => {
test("bookNumberToId[1] is GEN", () => {
expect(bookNumberToId[1]).toBe("GEN");
});
test("bookNumberToId[66] is REV", () => {
expect(bookNumberToId[66]).toBe("REV");
});
test("bookIdToNumber['GEN'] is 1", () => {
expect(bookIdToNumber["GEN"]).toBe(1);
});
test("bookIdToNumber['REV'] is 66", () => {
expect(bookIdToNumber["REV"]).toBe(66);
});
test("round-trip: number → ID → number", () => {
for (let i = 1; i <= 66; i++) {
const id = bookNumberToId[i];
expect(bookIdToNumber[id]).toBe(i);
}
});
test("round-trip: ID → number → ID", () => {
for (const book of bibleBooks) {
const num = bookIdToNumber[book.id];
expect(bookNumberToId[num]).toBe(book.id);
}
});
});

271
tests/game.test.ts Normal file
View File

@@ -0,0 +1,271 @@
import { describe, test, expect } from "bun:test";
import {
evaluateGuess,
getBookById,
getFirstLetter,
getGrade,
getNextGradeMessage,
isAdjacent,
toOrdinal,
} from "$lib/utils/game";
describe("getBookById", () => {
test("returns correct book for a valid ID", () => {
const book = getBookById("GEN");
expect(book).toBeDefined();
expect(book!.name).toBe("Genesis");
expect(book!.order).toBe(1);
});
test("returns the last book by ID", () => {
const book = getBookById("REV");
expect(book).toBeDefined();
expect(book!.name).toBe("Revelation");
expect(book!.order).toBe(66);
});
test("returns undefined for an invalid ID", () => {
expect(getBookById("INVALID")).toBeUndefined();
});
test("returns undefined for an empty string", () => {
expect(getBookById("")).toBeUndefined();
});
test("is case-sensitive", () => {
expect(getBookById("gen")).toBeUndefined();
});
});
describe("isAdjacent", () => {
test("consecutive books are adjacent", () => {
expect(isAdjacent("GEN", "EXO")).toBe(true); // 1, 2
});
test("adjacency is symmetric", () => {
expect(isAdjacent("EXO", "GEN")).toBe(true);
});
test("books two apart are not adjacent", () => {
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
});
test("the same book is not adjacent to itself", () => {
expect(isAdjacent("GEN", "GEN")).toBe(false); // diff = 0
});
test("works across testament boundary (Malachi / Matthew)", () => {
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
});
test("far-apart books are not adjacent", () => {
expect(isAdjacent("GEN", "REV")).toBe(false);
});
test("returns false for unknown IDs", () => {
expect(isAdjacent("FAKE", "GEN")).toBe(false);
expect(isAdjacent("GEN", "FAKE")).toBe(false);
});
});
describe("getFirstLetter", () => {
test("returns first letter of a normal book name", () => {
expect(getFirstLetter("Genesis")).toBe("G");
expect(getFirstLetter("Revelation")).toBe("R");
});
test("skips leading digits and returns first letter", () => {
expect(getFirstLetter("1 Samuel")).toBe("S");
expect(getFirstLetter("2 Kings")).toBe("K");
expect(getFirstLetter("1 Corinthians")).toBe("C");
expect(getFirstLetter("3 John")).toBe("J");
});
test("returns first letter of multi-word names", () => {
expect(getFirstLetter("Song of Solomon")).toBe("S");
});
});
describe("evaluateGuess", () => {
test("returns null for an invalid guess ID", () => {
expect(evaluateGuess("INVALID", "GEN")).toBeNull();
});
test("returns null for an invalid correct ID", () => {
expect(evaluateGuess("GEN", "INVALID")).toBeNull();
});
test("exact book match: testamentMatch and sectionMatch are true, adjacent is false", () => {
const result = evaluateGuess("GEN", "GEN");
expect(result).not.toBeNull();
expect(result!.book.id).toBe("GEN");
expect(result!.testamentMatch).toBe(true);
expect(result!.sectionMatch).toBe(true);
expect(result!.adjacent).toBe(false);
});
test("same section implies same testament", () => {
// Genesis and Exodus are both OT Law
const result = evaluateGuess("GEN", "EXO");
expect(result!.testamentMatch).toBe(true);
expect(result!.sectionMatch).toBe(true);
expect(result!.adjacent).toBe(true);
});
test("same testament, different section", () => {
// Genesis (Law) vs Joshua (History) — both OT
const result = evaluateGuess("GEN", "JOS");
expect(result!.testamentMatch).toBe(true);
expect(result!.sectionMatch).toBe(false);
expect(result!.adjacent).toBe(false);
});
test("different testament, no match", () => {
// Genesis (OT) vs Matthew (NT)
const result = evaluateGuess("GEN", "MAT");
expect(result!.testamentMatch).toBe(false);
expect(result!.sectionMatch).toBe(false);
expect(result!.adjacent).toBe(false);
});
test("adjacent books across testament boundary", () => {
// Malachi (OT, 39) and Matthew (NT, 40)
const result = evaluateGuess("MAL", "MAT");
expect(result!.adjacent).toBe(true);
expect(result!.testamentMatch).toBe(false);
expect(result!.sectionMatch).toBe(false);
});
test("adjacent books within same testament and section", () => {
// Hosea (28) and Joel (29), both Minor Prophets
const result = evaluateGuess("HOS", "JOL");
expect(result!.adjacent).toBe(true);
expect(result!.testamentMatch).toBe(true);
expect(result!.sectionMatch).toBe(true);
});
test("firstLetterMatch: same first letter", () => {
// Genesis and Galatians both start with G
const result = evaluateGuess("GEN", "GAL");
expect(result!.firstLetterMatch).toBe(true);
});
test("firstLetterMatch: different first letter", () => {
// Genesis (G) vs Matthew (M)
const result = evaluateGuess("GEN", "MAT");
expect(result!.firstLetterMatch).toBe(false);
});
test("firstLetterMatch is case-insensitive", () => {
// Both start with J but from different contexts — Jeremiah vs Joel
const result = evaluateGuess("JER", "JOL");
expect(result!.firstLetterMatch).toBe(true);
});
test("special case: two Epistle '1' books always firstLetterMatch", () => {
// 1 Corinthians (Pauline) vs 1 John (General) — both Epistles starting with "1"
const result = evaluateGuess("1CO", "1JN");
expect(result!.firstLetterMatch).toBe(true);
});
test("special case: Epistle '1' book vs non-Epistle '1' book — no special treatment", () => {
// 1 Corinthians (Pauline Epistles) vs 1 Samuel (History)
// Correct is NOT Epistles, so special case doesn't apply
const result = evaluateGuess("1CO", "1SA");
// getFirstLetter("1 Corinthians") = "C", getFirstLetter("1 Samuel") = "S" → false
expect(result!.firstLetterMatch).toBe(false);
});
test("special case only triggers when BOTH are Epistle '1' books", () => {
// 2 Corinthians (Pauline, starts with "2") vs 1 John (General, starts with "1")
// guessIsEpistlesWithNumber requires name[0] === "1", so 2CO fails
const result = evaluateGuess("2CO", "1JN");
// getFirstLetter("2 Corinthians") = "C", getFirstLetter("1 John") = "J" → false
expect(result!.firstLetterMatch).toBe(false);
});
});
describe("getGrade", () => {
test("1 guess → S+", () => expect(getGrade(1)).toBe("S+"));
test("2 guesses → A+", () => expect(getGrade(2)).toBe("A+"));
test("3 guesses → A", () => expect(getGrade(3)).toBe("A"));
test("4 guesses → B+", () => expect(getGrade(4)).toBe("B+"));
test("6 guesses → B+", () => expect(getGrade(6)).toBe("B+"));
test("7 guesses → B", () => expect(getGrade(7)).toBe("B"));
test("10 guesses → B", () => expect(getGrade(10)).toBe("B"));
test("11 guesses → C+", () => expect(getGrade(11)).toBe("C+"));
test("15 guesses → C+", () => expect(getGrade(15)).toBe("C+"));
test("16 guesses → C", () => expect(getGrade(16)).toBe("C"));
test("100 guesses → C", () => expect(getGrade(100)).toBe("C"));
});
describe("getNextGradeMessage", () => {
test("returns empty string at top grade", () => {
expect(getNextGradeMessage(1)).toBe("");
});
test("grade A+ shows 1 guess threshold", () => {
expect(getNextGradeMessage(2)).toBe("Next grade: 1 guess or less");
});
test("grade A shows 2 guess threshold", () => {
expect(getNextGradeMessage(3)).toBe("Next grade: 2 guesses or less");
});
test("grade B+ shows 3 guess threshold", () => {
expect(getNextGradeMessage(4)).toBe("Next grade: 3 guesses or less");
expect(getNextGradeMessage(6)).toBe("Next grade: 3 guesses or less");
});
test("grade B shows 6 guess threshold", () => {
expect(getNextGradeMessage(7)).toBe("Next grade: 6 guesses or less");
expect(getNextGradeMessage(10)).toBe("Next grade: 6 guesses or less");
});
test("grade C+ shows 10 guess threshold", () => {
expect(getNextGradeMessage(11)).toBe("Next grade: 10 guesses or less");
expect(getNextGradeMessage(15)).toBe("Next grade: 10 guesses or less");
});
test("grade C shows 15 guess threshold", () => {
expect(getNextGradeMessage(16)).toBe("Next grade: 15 guesses or less");
expect(getNextGradeMessage(50)).toBe("Next grade: 15 guesses or less");
});
});
describe("toOrdinal", () => {
test("1st, 2nd, 3rd", () => {
expect(toOrdinal(1)).toBe("1st");
expect(toOrdinal(2)).toBe("2nd");
expect(toOrdinal(3)).toBe("3rd");
});
test("4-10 use th", () => {
expect(toOrdinal(4)).toBe("4th");
expect(toOrdinal(10)).toBe("10th");
});
test("11, 12, 13 use th (not st/nd/rd)", () => {
expect(toOrdinal(11)).toBe("11th");
expect(toOrdinal(12)).toBe("12th");
expect(toOrdinal(13)).toBe("13th");
});
test("21, 22, 23 use st/nd/rd", () => {
expect(toOrdinal(21)).toBe("21st");
expect(toOrdinal(22)).toBe("22nd");
expect(toOrdinal(23)).toBe("23rd");
});
test("101, 102, 103 use st/nd/rd", () => {
expect(toOrdinal(101)).toBe("101st");
expect(toOrdinal(102)).toBe("102nd");
expect(toOrdinal(103)).toBe("103rd");
});
test("111, 112, 113 use th", () => {
expect(toOrdinal(111)).toBe("111th");
expect(toOrdinal(112)).toBe("112th");
expect(toOrdinal(113)).toBe("113th");
});
});

339
tests/share.test.ts Normal file
View File

@@ -0,0 +1,339 @@
import { describe, test, expect } from "bun:test";
import { generateShareText, getVerseSnippet } from "$lib/utils/share";
import { getBookById } from "$lib/utils/game";
import type { Guess } from "$lib/utils/game";
// Helpers to build Guess objects without calling evaluateGuess
function makeGuess(bookId: string, overrides: Partial<Omit<Guess, "book">> = {}): Guess {
const book = getBookById(bookId)!;
return {
book,
testamentMatch: false,
sectionMatch: false,
adjacent: false,
firstLetterMatch: false,
...overrides,
};
}
const CORRECT_BOOK_ID = "GEN";
const exactGuess = makeGuess("GEN", {
testamentMatch: true,
sectionMatch: true,
});
const adjacentGuess = makeGuess("EXO", {
testamentMatch: true,
sectionMatch: true,
adjacent: true,
});
const sectionGuess = makeGuess("LEV", {
testamentMatch: true,
sectionMatch: true,
});
const testamentGuess = makeGuess("JOS", {
testamentMatch: true,
sectionMatch: false,
});
const noMatchGuess = makeGuess("MAT", {
testamentMatch: false,
sectionMatch: false,
});
describe("generateShareText — emoji mapping", () => {
test("exact match → ✅", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("✅");
});
test("adjacent book → ‼️", () => {
const text = generateShareText({
guesses: [adjacentGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("‼️");
});
test("section match → 🟩", () => {
// LEV matches section (Law) but is not adjacent to GEN (order 1 vs 3)
const text = generateShareText({
guesses: [sectionGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("🟩");
});
test("testament match only → 🟧", () => {
const text = generateShareText({
guesses: [testamentGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("🟧");
});
test("no match → 🟥", () => {
const text = generateShareText({
guesses: [noMatchGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "In the beginning...",
});
expect(text).toContain("🟥");
});
});
describe("generateShareText — guess count wording", () => {
test("1 guess uses singular 'guess'", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("1 guess,");
});
test("multiple guesses uses plural 'guesses'", () => {
const text = generateShareText({
guesses: [noMatchGuess, testamentGuess, exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("3 guesses,");
});
});
describe("generateShareText — streak display", () => {
test("streak > 1 is shown with fire emoji", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
streak: 5,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("5 days 🔥");
});
test("streak of 1 is not shown", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
streak: 1,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).not.toContain("🔥");
});
test("undefined streak is not shown", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).not.toContain("🔥");
});
});
describe("generateShareText — chapter star", () => {
test("1 guess + chapterCorrect → ⭐ appended to emoji line", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: true,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("✅ ⭐");
});
test("multiple guesses + chapterCorrect → no star (only awarded for hole-in-one)", () => {
const text = generateShareText({
guesses: [noMatchGuess, exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: true,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).not.toContain("⭐");
});
test("1 guess + chapterCorrect false → no star", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).not.toContain("⭐");
});
});
describe("generateShareText — login book emoji", () => {
test("logged in uses 📜", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: true,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("📜");
expect(text).not.toContain("📖");
});
test("not logged in uses 📖", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("📖");
expect(text).not.toContain("📜");
});
});
describe("generateShareText — date formatting", () => {
test("date is formatted as 'Mon DD, YYYY'", () => {
const text = generateShareText({
guesses: [exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
expect(text).toContain("Jan 15, 2025");
});
});
describe("generateShareText — guess order", () => {
test("guesses are reversed in the emoji line (first guess last)", () => {
// noMatchGuess first, then exactGuess — reversed output: ✅🟥
const text = generateShareText({
guesses: [noMatchGuess, exactGuess],
correctBookId: CORRECT_BOOK_ID,
dailyVerseDate: "2025-01-15",
chapterCorrect: false,
isLoggedIn: false,
origin: "https://bibdle.com",
verseText: "...",
});
const lines = text.split("\n");
expect(lines[2]).toBe("✅🟥");
});
});
describe("getVerseSnippet", () => {
test("wraps output in curly double quotes", () => {
const result = getVerseSnippet("Hello world");
expect(result.startsWith("\u201C")).toBe(true);
expect(result.endsWith("\u201D")).toBe(true);
});
test("short verse (fewer than 10 words) returns full text", () => {
const result = getVerseSnippet("For God so loved");
// No punctuation search happens, returns all words
expect(result).toContain("For God so loved");
expect(result).toContain("...");
});
test("verse with no punctuation in range returns first 25 words", () => {
const words = Array.from({ length: 30 }, (_, i) => `word${i + 1}`);
const verse = words.join(" ");
const result = getVerseSnippet(verse);
// Should contain up to 25 words
expect(result).toContain("word25");
expect(result).not.toContain("word26");
});
test("truncates at punctuation between words 10 and 25", () => {
// 12 words before comma, rest after
const verse =
"one two three four five six seven eight nine ten eleven twelve, thirteen fourteen fifteen twenty";
const result = getVerseSnippet(verse);
// The comma is after word 12, which is between word 10 and 25
expect(result).toContain("twelve");
expect(result).not.toContain("thirteen");
});
test("punctuation before word 10 does not trigger truncation", () => {
// Comma is after word 5 — before the search window starts at word 10
const verse =
"one two three four five, six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen";
const result = getVerseSnippet(verse);
// The comma at word 5 is before start of search range, so we continue
// The snippet should contain word 10 at minimum
expect(result).toContain("ten");
});
test("does not include trailing whitespace before ellipsis", () => {
const verse =
"one two three four five six seven eight nine ten eleven twelve, rest of verse here";
const result = getVerseSnippet(verse);
// trimEnd is applied before adding ..., so no space before ...
expect(result).not.toMatch(/\s\.\.\./);
});
});

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

129
tests/stats.test.ts Normal file
View File

@@ -0,0 +1,129 @@
import { describe, test, expect } from "bun:test";
import {
formatDate,
getGradeColor,
getPerformanceMessage,
getStreakMessage,
} from "$lib/utils/stats";
describe("getGradeColor", () => {
test("S++ → purple", () => {
expect(getGradeColor("S++")).toBe("text-purple-600 bg-purple-100");
});
test("S+ → yellow", () => {
expect(getGradeColor("S+")).toBe("text-yellow-600 bg-yellow-100");
});
test("A+ → green", () => {
expect(getGradeColor("A+")).toBe("text-green-600 bg-green-100");
});
test("A → light green", () => {
expect(getGradeColor("A")).toBe("text-green-500 bg-green-50");
});
test("B+ → blue", () => {
expect(getGradeColor("B+")).toBe("text-blue-600 bg-blue-100");
});
test("B → light blue", () => {
expect(getGradeColor("B")).toBe("text-blue-500 bg-blue-50");
});
test("C+ → orange", () => {
expect(getGradeColor("C+")).toBe("text-orange-600 bg-orange-100");
});
test("C → red", () => {
expect(getGradeColor("C")).toBe("text-red-600 bg-red-100");
});
test("unknown grade → gray fallback", () => {
expect(getGradeColor("X")).toBe("text-gray-600 bg-gray-100");
expect(getGradeColor("")).toBe("text-gray-600 bg-gray-100");
});
});
describe("formatDate", () => {
test("formats a mid-year date", () => {
expect(formatDate("2024-07-04")).toBe("Jul 4");
});
test("formats a January date", () => {
expect(formatDate("2024-01-15")).toBe("Jan 15");
});
test("formats the last day of the year", () => {
expect(formatDate("2023-12-31")).toBe("Dec 31");
});
test("formats a single-digit day without leading zero", () => {
expect(formatDate("2025-03-01")).toBe("Mar 1");
});
test("year in input does not appear in output", () => {
expect(formatDate("2024-06-20")).not.toContain("2024");
});
});
describe("getStreakMessage", () => {
test("0 → prompt to start", () => {
expect(getStreakMessage(0)).toBe("Start your streak today!");
});
test("1 → encouragement", () => {
expect(getStreakMessage(1)).toBe("Keep it going!");
});
test("2 → X days strong", () => {
expect(getStreakMessage(2)).toBe("2 days strong!");
});
test("6 → X days strong (upper bound of that range)", () => {
expect(getStreakMessage(6)).toBe("6 days strong!");
});
test("7 → week streak message", () => {
expect(getStreakMessage(7)).toBe("7 day streak - amazing!");
});
test("29 → upper bound of week-streak range", () => {
expect(getStreakMessage(29)).toBe("29 day streak - amazing!");
});
test("30 → unstoppable message", () => {
expect(getStreakMessage(30)).toBe("30 days - you're unstoppable!");
});
test("100 → unstoppable message", () => {
expect(getStreakMessage(100)).toBe("100 days - you're unstoppable!");
});
});
describe("getPerformanceMessage", () => {
test("≤ 2 guesses → exceptional", () => {
expect(getPerformanceMessage(1)).toBe("Exceptional performance!");
expect(getPerformanceMessage(2)).toBe("Exceptional performance!");
});
test("≤ 4 guesses → great", () => {
expect(getPerformanceMessage(2.1)).toBe("Great performance!");
expect(getPerformanceMessage(4)).toBe("Great performance!");
});
test("≤ 6 guesses → good", () => {
expect(getPerformanceMessage(4.1)).toBe("Good performance!");
expect(getPerformanceMessage(6)).toBe("Good performance!");
});
test("≤ 8 guesses → room for improvement", () => {
expect(getPerformanceMessage(6.1)).toBe("Room for improvement!");
expect(getPerformanceMessage(8)).toBe("Room for improvement!");
});
test("> 8 guesses → keep practicing", () => {
expect(getPerformanceMessage(8.1)).toBe("Keep practicing!");
expect(getPerformanceMessage(20)).toBe("Keep practicing!");
});
});

View File

@@ -0,0 +1,498 @@
import { describe, test, expect, beforeEach, mock } from 'bun:test';
import { testDb as db } from '$lib/server/db/test';
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import crypto from 'node:crypto';
describe('Timezone-aware daily verse system', () => {
beforeEach(async () => {
// Clean up test data before each test
await db.delete(dailyVerses);
await db.delete(dailyCompletions);
});
describe('Daily verse retrieval', () => {
test('users in different timezones can see different verses at the same UTC moment', async () => {
// Simulate: It's 2024-01-15 23:00 UTC
// - Tokyo (UTC+9): 2024-01-16 08:00
// - New York (UTC-5): 2024-01-15 18:00
const tokyoDate = '2024-01-16';
const newYorkDate = '2024-01-15';
// Create verses for both dates
const tokyoVerse = {
id: crypto.randomUUID(),
date: tokyoDate,
bookId: 'GEN',
verseText: 'Tokyo verse',
reference: 'Genesis 1:1',
};
const newYorkVerse = {
id: crypto.randomUUID(),
date: newYorkDate,
bookId: 'EXO',
verseText: 'New York verse',
reference: 'Exodus 1:1',
};
await db.insert(dailyVerses).values([tokyoVerse, newYorkVerse]);
// Verify Tokyo user gets Jan 16 verse
const tokyoResult = await db
.select()
.from(dailyVerses)
.where(eq(dailyVerses.date, tokyoDate))
.limit(1);
expect(tokyoResult).toHaveLength(1);
expect(tokyoResult[0].bookId).toBe('GEN');
expect(tokyoResult[0].verseText).toBe('Tokyo verse');
// Verify New York user gets Jan 15 verse
const newYorkResult = await db
.select()
.from(dailyVerses)
.where(eq(dailyVerses.date, newYorkDate))
.limit(1);
expect(newYorkResult).toHaveLength(1);
expect(newYorkResult[0].bookId).toBe('EXO');
expect(newYorkResult[0].verseText).toBe('New York verse');
});
test('verse dates are stored in YYYY-MM-DD format', async () => {
const verse = {
id: crypto.randomUUID(),
date: '2024-01-15',
bookId: 'GEN',
verseText: 'Test verse',
reference: 'Genesis 1:1',
};
await db.insert(dailyVerses).values(verse);
const result = await db
.select()
.from(dailyVerses)
.where(eq(dailyVerses.id, verse.id))
.limit(1);
expect(result[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe('Completion tracking', () => {
test('completions are stored with user local date', async () => {
const userId = 'test-user-1';
const localDate = '2024-01-16'; // User's local date
const completion = {
id: crypto.randomUUID(),
anonymousId: userId,
date: localDate,
guessCount: 3,
completedAt: new Date(),
};
await db.insert(dailyCompletions).values(completion);
const result = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId))
.limit(1);
expect(result).toHaveLength(1);
expect(result[0].date).toBe(localDate);
});
test('users in different timezones can complete different date verses simultaneously', async () => {
const tokyoUser = 'tokyo-user';
const newYorkUser = 'newyork-user';
const completions = [
{
id: crypto.randomUUID(),
anonymousId: tokyoUser,
date: '2024-01-16', // Tokyo: Jan 16
guessCount: 2,
completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC
},
{
id: crypto.randomUUID(),
anonymousId: newYorkUser,
date: '2024-01-15', // New York: Jan 15
guessCount: 4,
completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC
},
];
await db.insert(dailyCompletions).values(completions);
const tokyoResult = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, tokyoUser))
.limit(1);
const newYorkResult = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, newYorkUser))
.limit(1);
expect(tokyoResult[0].date).toBe('2024-01-16');
expect(newYorkResult[0].date).toBe('2024-01-15');
});
});
describe('Streak calculation', () => {
test('consecutive days count as a streak', async () => {
const userId = 'streak-user';
const completions = [
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-13',
guessCount: 2,
completedAt: new Date('2024-01-13T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-14',
guessCount: 3,
completedAt: new Date('2024-01-14T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-15',
guessCount: 1,
completedAt: new Date('2024-01-15T12:00:00Z'),
},
];
await db.insert(dailyCompletions).values(completions);
const allCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const sortedDates = allCompletions.map((c) => c.date).sort();
// Verify consecutive dates
expect(sortedDates).toEqual(['2024-01-13', '2024-01-14', '2024-01-15']);
// Calculate streak
let streak = 1;
for (let i = 1; i < sortedDates.length; i++) {
const currentDate = new Date(sortedDates[i]);
const prevDate = new Date(sortedDates[i - 1]);
const daysDiff = Math.floor(
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysDiff === 1) {
streak++;
} else {
break;
}
}
expect(streak).toBe(3);
});
test('current streak is active if last completion was today', async () => {
const userId = 'current-streak-user';
const userToday = '2024-01-16';
const completions = [
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-14',
guessCount: 2,
completedAt: new Date('2024-01-14T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-15',
guessCount: 3,
completedAt: new Date('2024-01-15T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: userToday,
guessCount: 1,
completedAt: new Date('2024-01-16T12:00:00Z'),
},
];
await db.insert(dailyCompletions).values(completions);
const allCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const sortedDates = allCompletions.map((c) => c.date).sort();
const lastPlayedDate = sortedDates[sortedDates.length - 1];
const yesterdayDate = new Date(userToday);
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = yesterdayDate.toISOString().split('T')[0];
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
expect(isStreakActive).toBe(true);
expect(lastPlayedDate).toBe(userToday);
});
test('current streak is active if last completion was yesterday', async () => {
const userId = 'yesterday-streak-user';
const userToday = '2024-01-16';
const yesterdayDate = new Date(userToday);
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = yesterdayDate.toISOString().split('T')[0];
const completions = [
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-13',
guessCount: 2,
completedAt: new Date('2024-01-13T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-14',
guessCount: 3,
completedAt: new Date('2024-01-14T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: yesterday,
guessCount: 1,
completedAt: new Date(yesterday + 'T12:00:00Z'),
},
];
await db.insert(dailyCompletions).values(completions);
const allCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const sortedDates = allCompletions.map((c) => c.date).sort();
const lastPlayedDate = sortedDates[sortedDates.length - 1];
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
expect(isStreakActive).toBe(true);
expect(lastPlayedDate).toBe(yesterday);
});
test('current streak is not active if last completion was 2+ days ago', async () => {
const userId = 'broken-streak-user';
const userToday = '2024-01-16';
const completions = [
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-13',
guessCount: 2,
completedAt: new Date('2024-01-13T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-14',
guessCount: 3,
completedAt: new Date('2024-01-14T12:00:00Z'),
},
];
await db.insert(dailyCompletions).values(completions);
const allCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const sortedDates = allCompletions.map((c) => c.date).sort();
const lastPlayedDate = sortedDates[sortedDates.length - 1];
const yesterdayDate = new Date(userToday);
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = yesterdayDate.toISOString().split('T')[0];
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
expect(isStreakActive).toBe(false);
expect(lastPlayedDate).toBe('2024-01-14'); // 2 days ago
});
test('gap in dates breaks the streak', async () => {
const userId = 'gap-user';
const completions = [
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-10',
guessCount: 2,
completedAt: new Date('2024-01-10T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-11',
guessCount: 3,
completedAt: new Date('2024-01-11T12:00:00Z'),
},
// Gap here (no 01-12)
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-13',
guessCount: 1,
completedAt: new Date('2024-01-13T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-14',
guessCount: 2,
completedAt: new Date('2024-01-14T12:00:00Z'),
},
];
await db.insert(dailyCompletions).values(completions);
const allCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const sortedDates = allCompletions.map((c) => c.date).sort();
// Calculate best streak (should be 2, not 4)
let bestStreak = 1;
let tempStreak = 1;
for (let i = 1; i < sortedDates.length; i++) {
const currentDate = new Date(sortedDates[i]);
const prevDate = new Date(sortedDates[i - 1]);
const daysDiff = Math.floor(
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysDiff === 1) {
tempStreak++;
} else {
bestStreak = Math.max(bestStreak, tempStreak);
tempStreak = 1;
}
}
bestStreak = Math.max(bestStreak, tempStreak);
expect(bestStreak).toBe(2); // Longest streak is Jan 13-14 or Jan 10-11
});
});
describe('Date validation', () => {
test('date must be in YYYY-MM-DD format', () => {
const validDates = ['2024-01-15', '2023-12-31', '2024-02-29'];
validDates.forEach((date) => {
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
test('invalid date formats are rejected', () => {
const invalidDates = [
'2024/01/15', // Wrong separator
'01-15-2024', // Wrong order
'2024-1-15', // Missing leading zero
'2024-01-15T12:00:00Z', // Includes time
];
invalidDates.forEach((date) => {
if (date.includes('T')) {
expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/);
} else {
expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
});
});
});
describe('Edge cases', () => {
test('crossing year boundary maintains streak', async () => {
const userId = 'year-boundary-user';
const completions = [
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2023-12-30',
guessCount: 2,
completedAt: new Date('2023-12-30T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2023-12-31',
guessCount: 3,
completedAt: new Date('2023-12-31T12:00:00Z'),
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-01',
guessCount: 1,
completedAt: new Date('2024-01-01T12:00:00Z'),
},
];
await db.insert(dailyCompletions).values(completions);
const allCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const sortedDates = allCompletions.map((c) => c.date).sort();
let streak = 1;
for (let i = 1; i < sortedDates.length; i++) {
const currentDate = new Date(sortedDates[i]);
const prevDate = new Date(sortedDates[i - 1]);
const daysDiff = Math.floor(
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysDiff === 1) {
streak++;
}
}
expect(streak).toBe(3);
});
// Note: Duplicate prevention is handled by the API endpoint, not at the DB level in these tests
// See /api/submit-completion for the unique constraint enforcement
});
});

104
todo.md
View File

@@ -1,21 +1,33 @@
# in progress
- root menu: classic / imposter mode / impossible mode (complete today's classic and imposter modes to unlock)
# todo
- login
- login route
- impossible mode (1904 greek bible) three guesses only.
- share both classic and impossible mode with both buttons
- add imposter mode
- improve imposter mode
- Show new/old testament after 3 guesses and section after 7 guesses
- Add sections for "first letter", "Canonical/deutero", etc...
- How do you balance rewarding knowledge vs incentivising learning?
- instructions
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
- imposter mode: out of four options, identify the verse that is not in the Bible
- OR out of four options, identify the verse that is not in the same book as the other three options
- OR, out of four options, drag them into the
- impossible mode: identify which book of the bible the verse is from in less than three guesses.
- The gambling aspect of hoping you get a verse you already know is VERY strong
- add login + saved stats + streak etc.
- add deuterocanonical books
@@ -47,6 +59,94 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done
## feb 26th
- Added dark mode
- Removed URL from share text (Wordle said it was ratchet)
- added option for sharing with verse snippet (hidden on share text first copy)
## february 22nd
- New share button design; speech bubbles
- Share rate jumped from ~17% to ~27% (n=200) after share button redesign
- Updated streak-percentile to count all players from last 30 days and all streaks (in case there are streaks >30 days)
- Added copy verse button
- Refactored book search input to show progressively more info based on guess count
## february 21st
- Added streak counter and streak percentage
- Added Rybbit analytics alongside Umami
## february 18th-19th
- Refactored game logic into utility modules
- Small fixes to Sign In with Apple migrations
## february 13th
- Added Sign In with Apple
- Added animations on win and guess
- Various Apple auth bug fixes
## february 11th-12th
- Client-side timezone handling for daily verses (was using server time)
- Staggered page load animations
- Reordered guesses table with emphasis
- Redesigned stats page with dark theme and enhanced statistics
## february 5th-10th
- Added login modal and auth infrastructure
- Switched to `bun:sqlite`
- Support authenticated users in stats and page loading
- Anonymous stats migration on sign-in
- Test infrastructure and sign-in migration tests
## february 2nd
- created rss feed
- fixed "first letter" clue edge cases / easter egg
- updated ranking formula
## january 28th
- add percentile stats, update chapter guess UI
- fixed middle statline (removed meaningless %)
- added instructions
- added email button
- added test buttons for 3.0 UI/UX
- package upgrades
## january 26th
- Make the UI more "wordle-like"
- added deployment script (./deploy.sh)
- added bluesky button
- added "first letter" column
- added imposter mode, v0.1 (mom likes it) but needs work
## january 8th
- posted on Hacker News and LinkedIn, got 960 visitors in one day
## january 5th
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
- Verses ending in semicolons, commas, etc. will be replaced with "..."
## january 4th
- For bonus points: guess the verse/psalm number
- major UI styling revamp
-- 2026 --
## december 30th
- merged the embeddings/similarity route into production
## december 27th
- add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate)