32 Commits

Author SHA1 Message Date
George Powell
ae4482a551 refactor: move View Stats and Sign In/Out buttons into DevButtons
Consolidates the dev-only stats and auth buttons into the DevButtons
component, passing user and onSignIn as props. Also comments out the
Twitter link in SocialLinks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:47:03 -04:00
George Powell
884bbe65c7 feat: add about page, sitemap, social links component, Apple sign-in
prompt on win screen, and layout/theme improvements

  ## New features

  - **About page** (`src/routes/about/`): New static about page rendered
    from `static/about.md` using the `marked` library (added as a
    dependency). Includes the project backstory content.

  - **XML sitemap** (`src/routes/sitemap.xml/`): Dynamic sitemap
    endpoint for SEO, registered in `static/robots.txt` via `Sitemap:`
    directive.

  - **Apple Sign In prompt on win screen** (`WinScreen.svelte`): When
    the game is won and the user is not logged in, a "Sign in to save
    your streak & see your stats" prompt with an Apple Sign In button is
    shown below the share card. Passes `anonymousId` so stats migrate on
    sign-up. Driven by new `isLoggedIn` and `anonymousId` props, passed
    from `+page.svelte`.

  ## Refactoring

  - **`SocialLinks` component**
    (`src/lib/components/SocialLinks.svelte`): Extracted the Bluesky,
    Twitter/X, and email social link icons from `Credits.svelte` into a
    reusable component. `Credits.svelte` now imports and renders
    `<SocialLinks />`.

  - **`ThemeToggle` component**
    (`src/lib/components/ThemeToggle.svelte`): New component for
    toggling light/dark mode, persisted to `localStorage`. Currently
    rendered but hidden (`hidden` class) in `+page.svelte` —
    infrastructure is in place for future use.

  ## Layout changes

  - **`+layout.svelte`**: Moved the page title/header (`<h1>` with
    `TitleAnimation`) and the gradient background wrapper from
    `+page.svelte` into the root layout, so it applies across all
    routes. Also removed the `browser` guard around the analytics script
    injection (it's
    already inside `onMount` which is client-only). Added `<meta
    name="description">`.

  - **`+page.svelte`**: Removed the title/header and gradient wrapper
    (now in layout). Minor formatting cleanup (reformatted `SearchInput`
    props, moved `currentDate` derived state earlier). `ThemeToggle`
    import swapped in place of `TitleAnimation` (which moved to layout).

  ## Styling

  - **`layout.css`**: Added `@custom-variant dark` for class-based dark
    mode toggling (supports `.dark` class on `<html>`). Added explicit
    `html.dark` / `html.light` rules alongside the existing
    `prefers-color-scheme` media query, so the `ThemeToggle` component
    can
    override the system preference. Added background transition
    animation.
2026-03-12 18:22:59 -04:00
George Powell
3de55ba216 removed large xml and systemd service 2026-02-28 03:06:49 -05:00
George Powell
6e74fffb65 no longer initializes embeddings model on startup 2026-02-28 02:48:46 -05:00
George Powell
1ae2b2ac6c fix: remove trailing comma in share text when there is no streak
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:13:13 -05:00
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
58 changed files with 3204 additions and 696 deletions

3
.gitignore vendored
View File

@@ -30,3 +30,6 @@ llms-*
embeddings*
*bible.xml
engwebu_usfx.xml
deploy.log
bibdle.socket

132
CLAUDE.md
View File

@@ -27,19 +27,14 @@ After calling the list-sections tool, you MUST analyze the returned documentatio
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
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
- **Styling**: Tailwind CSS 4
- **Database**: SQLite with Drizzle ORM
- **Auth**: Session-based authentication using @oslojs/crypto (SHA-256 hashed tokens)
- **Auth**: Session-based authentication using Bun's built-in cryptographically secure functions
- **Deployment**: Node.js adapter for production builds
- **External API**: bible-api.com for fetching random verses
- **ML**: `@xenova/transformers` for verse embeddings (initialized in server hook) (currently disabled, was a test for a cancelled project)
## Development Commands
@@ -51,6 +46,11 @@ bun run dev
bun run check
bun run check:watch
# Run tests
bun test
bun test --watch
bun test tests/timezone-handling.test.ts # Run a single test file
# Build for production
bun run build
@@ -58,92 +58,108 @@ bun run build
bun run preview
# Database operations
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:push # Push schema changes directly (avoid in prod)
bun run db:generate # Generate migrations
bun run db:migrate # Run migrations
bun run db:studio # Open Drizzle Studio GUI
```
## Critical: Date/Time Handling
**Bibdle is played by users across many timezones worldwide. The verse shown to a player must always be the verse for the calendar date at *their* location — not the server's timezone, not UTC. A user in Tokyo on Wednesday must see Wednesday's verse, even if the server (or a user in New York) is still on Tuesday.**
**NEVER use server time or UTC time for user-facing date calculations.**
- Get today's date client-side: `new Date().toLocaleDateString("en-CA")``YYYY-MM-DD`
- Pass the date to the server as a query param or POST body (`localDate`)
- Server-side date arithmetic must use UTC methods on the client-provided date string: `new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`
- `src/routes/+page.ts` has `ssr = false` so the load runs client-side with the true local date
- Never set the user-facing URL to include their date as a parameter. It should always be passed to an API route behind the scenes if needed.
### Streak Calculation
A streak counts consecutive calendar days (in the user's local timezone) on which the user completed the puzzle. The rules:
- The client passes its local date (`localDate`) to the streak API. The server never uses its own clock.
- A streak is **active** if the user has completed today's puzzle *or* yesterday's puzzle (they still have time to play today).
- Walk backwards from `localDate` through the `dailyCompletions` records, counting each day that has a completion. Stop as soon as a day is missing.
- A streak of 1 (completed only today or only yesterday, with no prior consecutive days) is **not displayed** — the minimum shown streak is 2.
- "Yesterday" and all date arithmetic on the server must use UTC methods on the client-provided date string to avoid timezone drift: `new Date(localDate + 'T00:00:00Z')`, then `setUTCDate`/`getUTCDate`.
## Architecture
### Database Schema (`src/lib/server/db/schema.ts`)
- **user**: User accounts with id and age
- **session**: Auth sessions linked to users with expiration timestamps
- **user**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `isPrivate`
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
- **dailyCompletions**: Game results per user/date with guess count, grade, book; unique on `(userId, date)`
Sessions expire after 30 days and are automatically renewed when less than 15 days remain.
Sessions expire after 30 days and auto-renew when < 15 days remain.
### Bible Data (`src/lib/types/bible.ts`)
The `bibleBooks` array contains all 66 Bible books with metadata:
- Testament (old/new)
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
- Testament (old/new), Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
- Order (1-66, used for adjacency detection)
- Popularity (2-10, affects grading - higher is more popular)
### Daily Verse System (`src/routes/+page.server.ts`)
The `getTodayVerse()` function:
1. Checks database for existing verse for today's date
2. If none exists, fetches from bible-api.com (random verse + 2 consecutive verses)
3. Caches in database with UTC date key
4. Returns verse with book metadata for the game
`getTodayVerse()` checks the database for today's date, fetches a verse if missing, caches permanently, and returns verse with book metadata.
### Game Logic (`src/routes/+page.svelte`)
**State Management:**
- `guesses` array stores game state in localStorage keyed by date
- `guesses` array stored in localStorage keyed by date: `bibdle-guesses-${date}`
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
- `isWon` is derived from whether any guess matches the correct book
- `isWon` derived from whether any guess matches the correct book
**Grading System:**
```javascript
// Grade formula combines performance + difficulty
performanceScore = max(0, 10 - numGuesses)
difficulty = 14 - popularity
totalScore = performanceScore + difficulty * 0.8
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
```
**Hint System:**
- ✅ Green checkmark: Exact match
- 🟩 Green square: Section matches
- 🟧 Orange square: Testament matches (shared results)
- ‼️ Double exclamation: Adjacent book in Bible order
- 🟥 Red square: No match
**Hint System, for share grid:**
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
### Authentication System (`src/lib/server/auth.ts`)
- Token-based sessions with SHA-256 hashing
- Cookies store session tokens, validated on each request
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
- Note: Currently the schema includes user table but auth UI is not yet implemented
- Token generation: base64-encoded random bytes; stored as SHA-256 hash in DB
- Cookie name: `auth-session`
- Anonymous users: identified by a client-generated ID; stats migrate on sign-up via `migrateAnonymousStats()`
- Apple Sign-In supported via `appleId` field
### Stats & Streak (`src/routes/stats/`)
- Stats page requires auth; returns `requiresAuth: true` if unauthenticated
- Streak calculated client-side by calling `GET /api/streak?userId=X&localDate=Y`
- Streak walk-back: counts consecutive days backwards from `localDate` through completed dates
- Minimum displayed streak is 2 (single-day streaks suppressed)
## API Endpoints
- `POST /api/daily-verse` — Fetch verse for a specific date
- `POST /api/submit-completion` — Submit game result with stats
- `GET /api/streak?userId=X&localDate=Y` — Current streak for user
- `GET /api/streak-percentile` — Streak percentile ranking
## Key Files
- `src/routes/+page.svelte` - Main game UI and client-side logic
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
- `src/lib/server/bible.ts` - Bible book utility functions
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
- `src/hooks.server.ts` - SvelteKit server hook for session validation
- `src/routes/+page.svelte` Main game UI and client-side logic
- `src/routes/+page.server.ts` / `+page.ts` — Server load (verse) + client load (`ssr: false`)
- `src/routes/stats/+page.svelte` / `+page.server.ts` — Stats UI and server calculations
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous migration
- `src/lib/server/bible-api.ts` — Random verse fetching from local XML Bible
- `src/lib/server/bible.ts` — Bible book utility functions
- `src/lib/types/bible.ts` — Bible books data and TypeScript types
- `src/lib/server/db/schema.ts` — Drizzle ORM schema
- `src/hooks.server.ts` — Session validation hook; initializes ML embeddings
- `tests/` — Bun test suites (timezone, game, bible, stats, share, auth migration)
## Environment Variables
Required in `.env`:
- `DATABASE_URL` - Path to SQLite database file (e.g., `./local.db`)
- `DATABASE_URL` Path to SQLite database file (e.g., `./local.db`)
## Deployment
The project uses `@sveltejs/adapter-node` for deployment. The build output is a Node.js server that can be run with systemd or similar process managers. See `bibdle.service` and `bibdle.socket` for systemd configuration.
Uses `@sveltejs/adapter-node`. See `bibdle.service` systemd configuration.
## Important Notes
## A Note
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) - not stores or reactive declarations
- The schema includes authentication tables but the login/signup UI is not yet implemented
- Daily verses are cached permanently in the database to ensure consistency
- LocalStorage persists guesses per day using the key pattern `bibdle-guesses-${date}`
- The game validates book IDs from the API against the hardcoded `bibleBooks` array
The main developer of this project is still learning a lot about developing full-stack applications. If they ask you to do something, make sure they understand how it will be implemented before proceeding.

View File

@@ -1,18 +1,19 @@
[Unit]
Description=Bibdle SvelteKit App
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
Requires=bibdle.socket
After=network-online.target bibdle.socket
After=network-online.target
[Service]
Environment=NODE_ENV=production
Environment=ORIGIN=https://bibdle.com
Environment=DATABASE_URL=local.db
Environment=IDLE_TIMEOUT=60
WorkingDirectory=/home/george/projects/bibdle
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
Environment=DATABASE_URL=prod.db
Environment=IDLE_TIMEOUT=300
Environment=PORT=5173
WorkingDirectory=/home/xenia/projects/bibdle
#ExecStart=/home/xenia/.nvm/versions/node/v24.13.0/bin/node build/index.js
ExecStart=/home/xenia/.bun/bin/bun --bun build/index.js
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@@ -1,5 +0,0 @@
[Socket]
ListenStream=5173
[Install]
WantedBy=sockets.target

View File

@@ -7,11 +7,10 @@
"dependencies": {
"@xenova/transformers": "^2.17.2",
"fast-xml-parser": "^5.3.3",
"marked": "^17.0.4",
"xml2js": "^0.6.2",
},
"devDependencies": {
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
@@ -100,14 +99,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
@@ -404,6 +395,8 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],

View File

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

View File

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

View File

@@ -18,8 +18,6 @@
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
@@ -38,6 +36,7 @@
"dependencies": {
"@xenova/transformers": "^2.17.2",
"fast-xml-parser": "^5.3.3",
"marked": "^17.0.4",
"xml2js": "^0.6.2"
}
}

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

@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
// Initialize embeddings on server start (runs once on module load)
const verses = getAllNKJVVerses();
await initializeEmbeddings(verses);
// await initializeEmbeddings(verses);

View File

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

View File

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

View File

@@ -50,30 +50,30 @@
});
</script>
<div class="w-full">
<div class="w-full flex flex-col flex-1">
<div
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm w-full flex-1"
>
{#if newVerseReady}
<p
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
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 font-bold mt-2"
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 font-bold mb-2"
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 tabular-nums"
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
>
{timeUntilNext}
</p>

View File

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

View File

@@ -1,7 +1,44 @@
<script lang="ts">
import { browser } from "$app/environment";
import { enhance } from "$app/forms";
import Button from "$lib/components/Button.svelte";
type User = {
id: string;
email?: string | null;
firstName?: string | null;
lastName?: string | null;
appleId?: string | null;
} | null;
let {
anonymousId,
user,
onSignIn,
}: { anonymousId: string | null; user: User; onSignIn: () => void } = $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
@@ -22,6 +59,35 @@
<div class="border-t-2 border-gray-400"></div>
</div>
<div class="flex flex-col gap-3">
<a
href="/stats?{user
? `userId=${user.id}`
: `anonymousId=${anonymousId}`}&tz={encodeURIComponent(
Intl.DateTimeFormat().resolvedOptions().timeZone,
)}"
class="inline-flex items-center justify-center w-full px-4 py-4 md: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">
<button
type="submit"
class="inline-flex items-center justify-center w-full px-4 py-4 md: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={onSignIn}
class="inline-flex items-center justify-center w-full px-4 py-4 md: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 class="flex flex-col md:flex-row gap-3 md:gap-2">
<Button
variant="secondary"
@@ -86,4 +152,21 @@
>
Clear LocalStorage
</Button>
<Button
variant="secondary"
onclick={() => seedHistory(1)}
disabled={seeding}
class="w-full py-4 md:py-2"
>
{seeding ? "Seeding..." : "Add 1 Day of History"}
</Button>
<Button
variant="secondary"
onclick={() => seedHistory(10)}
disabled={seeding}
class="w-full py-4 md:py-2"
>
{seeding ? "Seeding..." : "Seed 10 Days of History"}
</Button>
</div>

View File

@@ -66,38 +66,37 @@
{#if !hasGuesses}
<Container class="p-6 text-center">
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
<h2 class="font-triodion text-xl italic mb-3 text-gray-800 dark:text-gray-100">
Instructions
</h2>
<p class="text-gray-700 leading-relaxed italic">
<p class="text-gray-700 dark:text-gray-300 leading-relaxed italic">
Guess what book of the bible you think the verse is from. You will
get clues to tell you if your guess is close or not. Green means the
category is correct; red means wrong.
get clues to help you after each guess.
</p>
</Container>
{:else}
<div class="space-y-3">
<!-- Column Headers -->
<div
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
>
Testament
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
>
Section
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
>
First Letter
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
>
Book
</div>

View File

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

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
</script>
<div class="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>

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

@@ -0,0 +1,62 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let isDarkMode = $state(false);
onMount(() => {
const stored = localStorage.getItem('bibdle-theme');
if (stored === 'dark') {
isDarkMode = true;
} else if (stored === 'light') {
isDarkMode = false;
} else {
isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
});
$effect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
localStorage.setItem('bibdle-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
localStorage.setItem('bibdle-theme', 'light');
}
});
function toggleTheme() {
isDarkMode = !isDarkMode;
}
</script>
{#if browser}
<button
onclick={toggleTheme}
aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
class="flex items-center gap-2 p-1 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 transition-colors duration-200 cursor-pointer"
>
{#if isDarkMode}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2"/>
<path d="M12 20v2"/>
<path d="m4.93 4.93 1.41 1.41"/>
<path d="m17.66 17.66 1.41 1.41"/>
<path d="M2 12h2"/>
<path d="M20 12h2"/>
<path d="m6.34 17.66-1.41 1.41"/>
<path d="m19.07 4.93-1.41 1.41"/>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
{/if}
<span class="text-xs uppercase tracking-widest">
{isDarkMode ? 'Light mode' : 'Dark mode'}
</span>
</button>
{/if}

View File

@@ -1,73 +1,87 @@
<script lang="ts">
import { browser } from "$app/environment";
import { fade } from "svelte/transition";
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
import Container from "./Container.svelte";
import { browser } from "$app/environment";
import { fade } from "svelte/transition";
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
import Container from "./Container.svelte";
let {
data,
isWon,
blurChapter = false,
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived(
blurChapter
? dailyVerse.reference
.replace(/^Psalms /, "Psalm ")
.replace(/\s(\d+):/, " ?:")
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
);
let displayVerseText = $derived(
dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase())
.replace(/[,:;-—]$/, "...")
);
let {
data,
isWon,
blurChapter = false,
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived(
blurChapter
? dailyVerse.reference
.replace(/^Psalms /, "Psalm ")
.replace(/\s(\d+):/, " ?:")
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
);
let displayVerseText = $derived(
dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase())
.replace(/[,:;-—]$/, "..."),
);
let showReference = $state(false);
let showReference = $state(false);
let copied = $state(false);
// Delay showing reference until GuessesTable animation completes
$effect(() => {
if (!isWon) {
showReference = false;
return;
}
// 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";
// 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);
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);
}
});
return () => clearTimeout(timeoutId);
}
});
function copyVerse() {
navigator.clipboard.writeText(displayVerseText).then(() => {
copied = true;
(window as any).rybbit?.event("Copy Verse");
setTimeout(() => {
copied = false;
}, 2000);
});
}
</script>
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
<blockquote
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 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! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
>
{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,13 +1,14 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { fade, fly } from "svelte/transition";
import { getBookById, toOrdinal } from "$lib/utils/game";
import {
getBookById,
toOrdinal,
getNextGradeMessage,
} from "$lib/utils/game";
import { onMount } from "svelte";
getVerseSnippet,
shareResult,
copyToClipboard as clipboardCopy,
} from "$lib/utils/share";
import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte";
import StreakCounter from "./StreakCounter.svelte";
import ChapterGuess from "./ChapterGuess.svelte";
interface StatsData {
@@ -25,7 +26,6 @@
}
let {
grade,
statsData,
correctBookId,
handleShare,
@@ -35,6 +35,28 @@
guessCount,
reference,
onChapterGuessCompleted,
shareText,
verseText,
streak = 0,
streakPercentile = null,
isLoggedIn = false,
anonymousId = '',
}: {
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;
isLoggedIn?: boolean;
anonymousId?: string;
} = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
@@ -42,6 +64,24 @@
typeof navigator !== "undefined" && "share" in navigator,
);
let copySuccess = $state(false);
let bubbleCopied = $state(false);
let copyTracked = $state(false);
let showSnippetOption = $state(false);
let includeSnippet = $state(false);
let effectiveShareText = $derived(
includeSnippet
? (() => {
const snippet = getVerseSnippet(verseText);
const lines = shareText.split("\n");
return [
...lines.slice(0, -1),
snippet,
lines[lines.length - 1],
].join("\n");
})()
: shareText,
);
// List of congratulations messages with weights
const congratulationsMessages: WeightedMessage[] = [
@@ -57,9 +97,9 @@
if (guessCount === 1) {
const n = Math.random();
if (n < 0.99) {
return "🌟 First try! 🌟";
return "First try!";
} else {
return "🗣️ Axios! 🗣️";
return "Axios!";
}
}
@@ -86,66 +126,27 @@
<div class="flex flex-col gap-6">
<Container
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
>
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
{congratulationsMessage} The verse is from
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
</p>
<p class="text-lg sm:text-xl md:text-2xl mt-4">
You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}.
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
>{grade}</span
>
</p>
<div class="flex justify-center mt-6">
{#if hasWebShare}
<!-- mobile and arc in production -->
<button
onclick={handleShare}
data-umami-event="Share"
class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
>
📤 Share
</button>
<button
onclick={() => {
copyToClipboard();
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}}
data-umami-event="Copy to Clipboard"
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
copySuccess
? "bg-white/30"
: "bg-white/70 hover:bg-white/80"
}`}
>
{copySuccess ? "✅ Copied!" : "📋 Copy"}
</button>
{:else}
<!-- dev mode and desktop browsers -->
<button
onclick={handleShare}
data-umami-event="Copy to Clipboard"
class={`text-2xl font-bold p-4 ${
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
>
{copied ? "✅ Copied!" : "📋 Share"}
</button>
{/if}
</div>
{#if guessCount !== 1}
<p class="pt-6 big-text text-gray-700!">
{getNextGradeMessage(guessCount)}
<div class="flex flex-col gap-3">
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
{congratulationsMessage} The verse is from
<span class="font-black font-triodion text-3xl md:text-4xl"
>{bookName}</span
>.
</p>
{/if}
<p class="text-lg sm:text-xl md:text-2xl">
You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}.
</p>
<!-- {#if streak >= 7}
<p
class="italic tracking-wider px-8 font-semibold text-gray-500"
>
Thank you for making BIBDLE part of your daily routine!
</p>
{/if} -->
</div>
</Container>
<!-- S++ Bonus Challenge for first try -->
@@ -157,12 +158,21 @@
/>
{/if}
<CountdownTimer />
<div class="flex flex-row gap-3 items-stretch w-full">
<div class="flex-2 min-w-0 flex flex-col">
<CountdownTimer />
</div>
{#if streak > 0}
<div class="flex-1 min-w-0 flex flex-col">
<StreakCounter {streak} {streakPercentile} />
</div>
{/if}
</div>
<!-- Statistics Display -->
{#if statsData}
<Container
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
>
<div
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
@@ -171,7 +181,7 @@
<!-- Solve Rank Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
#{statsData.solveRank}
</div>
@@ -184,7 +194,7 @@
<!-- Guess Rank Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
{toOrdinal(statsData.guessRank)}
</div>
@@ -206,7 +216,7 @@
<!-- Average Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
{statsData.averageGuesses}
</div>
@@ -220,11 +230,117 @@
</Container>
{:else if !statsSubmitted}
<Container
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
>
<div class="text-sm opacity-80">Submitting stats...</div>
</Container>
{/if}
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
<div class="big-text font-black! text-center">Share your result</div>
<div class="chat-window">
<!-- Received bubble: primary action (share / copy) -->
<div class="bubble-wrapper received-wrapper">
<button
class="bubble bubble-received"
class:success={copySuccess}
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
data-umami-event={hasWebShare
? "Share"
: "Copy to Clipboard"}
onclick={() => {
if (hasWebShare) {
(window as any).rybbit?.event("Share");
shareResult(effectiveShareText);
} else {
if (!copyTracked) {
(window as any).rybbit?.event(
"Copy to Clipboard",
);
copyTracked = true;
}
clipboardCopy(effectiveShareText);
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}
}}
>
{#if hasWebShare}
📤 Tap here to share
{:else if copySuccess}
✅ Copied!
{:else}
📋 Copy to clipboard
{/if}
</button>
</div>
<!-- Sent bubble: share text preview -->
<div class="bubble-wrapper">
<button
class="bubble bubble-sent"
aria-label="Copy to clipboard"
data-umami-event="Copy to Clipboard"
onclick={() => {
if (!copyTracked) {
(window as any).rybbit?.event("Copy to Clipboard");
copyTracked = true;
}
clipboardCopy(effectiveShareText);
showSnippetOption = true;
bubbleCopied = true;
setTimeout(() => {
bubbleCopied = false;
}, 2000);
}}>{effectiveShareText}</button
>
{#if hasWebShare}
<span class="copy-hint"
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
>
{:else}
<span class="copy-hint"
>{bubbleCopied ? "copied!" : ""}</span
>
{/if}
</div>
</div>
</div>
{#if showSnippetOption}
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
<span class="snippet-label">Show verse snippet in share?</span>
<button
class="snippet-toggle"
class:on={includeSnippet}
onclick={() => (includeSnippet = !includeSnippet)}
aria-pressed={includeSnippet}
aria-label="Show snippet in share"
>
<span class="toggle-thumb"></span>
</button>
</div>
{/if}
{#if !isLoggedIn}
<div class="signin-prompt">
<p class="signin-text">Sign in to save your streak &amp; see your stats</p>
<form method="POST" action="/auth/apple">
<input type="hidden" name="anonymousId" value={anonymousId} />
<button
type="submit"
class="apple-signin-btn"
>
<svg class="apple-icon" 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>
{/if}
</div>
<style>
@@ -239,7 +355,336 @@
}
}
.fade-in {
:global(.fade-in) {
animation: fadeIn 0.5s ease-out;
}
/* ── Share card ── */
.share-card {
background: oklch(94% 0.028 298.626);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 1.25rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
.share-card {
background: oklch(22% 0.025 298.626);
border-color: rgba(255, 255, 255, 0.1);
}
}
.share-card::before {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
opacity: 0.04;
pointer-events: none;
}
/* ── Chat window ── */
.chat-window {
--sent-color: #0b93f6;
--received-color: #3a3a3c;
--bg: oklch(94% 0.028 298.626);
display: flex;
flex-direction: column;
padding: 0 0.5rem 0;
gap: 0.6rem;
}
@media (prefers-color-scheme: dark) {
.chat-window {
--bg: oklch(22% 0.025 298.626);
}
}
/* ── Bubble wrappers ── */
.bubble-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.received-wrapper {
align-items: flex-start;
}
/* ── Shared bubble base ── */
.bubble {
position: relative;
max-width: 255px;
margin-bottom: 0;
padding: 10px 20px;
line-height: 1.3;
word-wrap: break-word;
border-radius: 25px;
text-align: left;
white-space: pre-wrap;
font-size: 1rem;
cursor: pointer;
transition:
filter 80ms ease,
transform 80ms ease;
user-select: none;
}
/* ── Sent bubble (share text preview) ── */
.bubble-sent {
color: white;
background: var(--sent-color);
transform: rotate(-2deg);
}
.bubble-sent:hover {
background-color: #2ea8ff;
transform: rotate(-2deg) translateY(-2px);
}
.bubble-sent:hover::before {
background-color: #2ea8ff;
}
.bubble-sent:active {
background-color: #0878d4;
transform: rotate(-2deg) scale(0.97);
}
.bubble-sent:active::before {
background-color: #0878d4;
}
/* Sent tail: bottom-right */
.bubble-sent::before,
.bubble-sent::after {
position: absolute;
bottom: 0;
height: 25px;
content: "";
}
.bubble-sent::before {
width: 20px;
right: -7px;
background-color: var(--sent-color);
border-bottom-left-radius: 16px 14px;
}
.bubble-sent::after {
width: 26px;
right: -26px;
border-bottom-left-radius: 10px;
background-color: var(--bg);
}
/* ── Received bubble (action button) ── */
.bubble-received {
color: #f5f5f7;
background: var(--received-color);
transform: rotate(2deg);
padding: 14px 24px;
font-size: 1.1rem;
font-weight: 700;
min-width: 14rem;
text-align: center;
}
.bubble-received:hover {
background-color: #4a4a4e;
transform: rotate(2deg) translateY(-2px);
}
.bubble-received:hover::before {
background-color: #4a4a4e;
}
.bubble-received:active {
background-color: #2a2a2c;
transform: rotate(2deg) scale(0.97);
}
.bubble-received:active::before {
background-color: #2a2a2c;
}
.bubble-received.success {
background: #c7f7d4;
color: #155724;
}
/* Received tail: bottom-left (mirror of sent) */
.bubble-received::before,
.bubble-received::after {
position: absolute;
bottom: 0;
height: 25px;
content: "";
}
.bubble-received::before {
width: 20px;
left: -7px;
background-color: var(--received-color);
border-bottom-right-radius: 16px 14px;
}
.bubble-received::after {
width: 26px;
left: -26px;
border-bottom-right-radius: 10px;
background-color: var(--bg);
}
.bubble-received.success::before {
background-color: #c7f7d4;
}
/* ── Copy hints ── */
.copy-hint {
font-size: 0.68rem;
color: #444;
font-weight: 400;
letter-spacing: 0.01em;
padding-right: 32px;
transform: rotate(-2deg);
transform-origin: right center;
margin-top: -6px;
}
@media (prefers-color-scheme: dark) {
.copy-hint {
color: #aaa;
}
}
/* ── Snippet toggle row ── */
.snippet-toggle-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 0.25rem;
}
.snippet-label {
font-size: 0.72rem;
color: #666;
letter-spacing: 0.01em;
user-select: none;
}
@media (prefers-color-scheme: dark) {
.snippet-label {
color: #999;
}
}
.snippet-toggle {
position: relative;
width: 36px;
height: 20px;
border-radius: 10px;
background: #ccc;
border: none;
cursor: pointer;
transition: background 200ms ease;
flex-shrink: 0;
padding: 0;
}
.snippet-toggle.on {
background: #34c759;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 200ms ease;
}
.snippet-toggle.on .toggle-thumb {
transform: translateX(16px);
}
/* ── Apple Sign In prompt ── */
.signin-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0 0.25rem;
}
.signin-text {
font-size: 0.85rem;
color: #555;
text-align: center;
font-weight: 500;
}
@media (prefers-color-scheme: dark) {
.signin-text {
color: #aaa;
}
}
.apple-signin-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem 1.5rem;
background: #000;
color: #fff;
border-radius: 0.5rem;
font-size: 0.95rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: background 150ms ease, transform 80ms ease;
}
.apple-signin-btn:hover {
background: #222;
transform: translateY(-1px);
}
.apple-signin-btn:active {
background: #111;
transform: scale(0.98);
}
@media (prefers-color-scheme: dark) {
.apple-signin-btn {
background: #fff;
color: #000;
}
.apple-signin-btn:hover {
background: #e5e5e5;
}
.apple-signin-btn:active {
background: #ccc;
}
}
.apple-icon {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
}
</style>

View File

@@ -1,5 +1,3 @@
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';
@@ -26,8 +24,8 @@ export async function generateAppleClientSecret(): Promise<string> {
sub: Bun.env.APPLE_ID!
};
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signingInput = `${encodedHeader}.${encodedPayload}`;
// Import PEM private key
@@ -55,7 +53,7 @@ export async function generateAppleClientSecret(): Promise<string> {
// 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);
const encodedSignature = Buffer.from(rawSignature).toString('base64url');
return `${signingInput}.${encodedSignature}`;
}

View File

@@ -1,7 +1,5 @@
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';
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
return Buffer.from(bytes).toString('base64url');
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
const session: table.Session = {
id: sessionId,
userId,
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
const [result] = await db
.select({
// Adjust user table here to tweak returned data

View File

@@ -1,7 +1,5 @@
import type { RequestEvent } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
return Buffer.from(bytes).toString('base64url');
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
const session: table.Session = {
id: sessionId,
userId,
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
const [result] = await db
.select({
// Adjust user table here to tweak returned data

View File

@@ -1,5 +1,4 @@
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(),

View File

@@ -1,6 +1,8 @@
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";
@@ -12,11 +14,21 @@ function getOrCreateAnonymousId(): string {
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,
getUserId: () => string | undefined,
getUser: () => AuthUser | null | undefined,
) {
let guesses = $state<Guess[]>([]);
let anonymousId = $state("");
@@ -24,27 +36,41 @@ export function createGamePersistence(
let chapterGuessCompleted = $state(false);
let chapterCorrect = $state(false);
// Initialize anonymous ID and load persisted flags
// 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 userId = getUserId();
const user = getUser();
// CRITICAL: If user is logged in, ALWAYS use their user ID
if (userId) {
anonymousId = userId;
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) {
@@ -58,7 +84,9 @@ export function createGamePersistence(
}
});
// Load saved guesses from localStorage
// 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;
@@ -72,13 +100,14 @@ export function createGamePersistence(
}
let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds));
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
guesses = savedIds
.map((bookId) => evaluateGuess(bookId, correctBookId))
.filter((g): g is Guess => g !== null);
});
// Save guesses to localStorage whenever they change
// 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();
@@ -88,12 +117,16 @@ export function createGamePersistence(
);
});
// 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()}`;
@@ -102,11 +135,16 @@ export function createGamePersistence(
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();
@@ -114,10 +152,10 @@ export function createGamePersistence(
guesses = guessIds
.map((bookId) => evaluateGuess(bookId, correctBookId))
.filter((g): g is Guess => g !== null);
// Persist to localStorage so subsequent loads on this device skip the server check
localStorage.setItem(`bibdle-guesses-${date}`, JSON.stringify(guessIds));
}
// 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;

View File

@@ -17,75 +17,73 @@ export interface BibleBook {
testament: Testament;
section: BibleSection;
order: number;
url: string;
popularity: number;
}
export const bibleBooks: BibleBook[] = [
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1, url: 'https://bible-api.com/data/web/GEN', popularity: 8 },
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66 }
];

View File

@@ -79,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;

View File

@@ -1,16 +1,53 @@
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;
grade: string;
chapterCorrect: boolean;
isLoggedIn: boolean;
userStreak?: number;
streak?: number;
origin: string;
verseText: string;
}): string {
const { guesses, correctBookId, dailyVerseDate, grade, chapterCorrect, isLoggedIn, userStreak, origin } = params;
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
const emojis = guesses
.slice()
@@ -35,20 +72,16 @@ export function generateShareText(params: {
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}`,
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
`${guesses.length} ${guessWord}${streakPart ? `,${streakPart}` : ""}`,
`${emojis}${chapterStar}`
];
if (isLoggedIn && userStreak !== undefined) {
lines.push(`🔥 ${userStreak} day streak`);
}
lines.push(
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
origin,
);
return lines.join("\n");
}

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,26 +1,35 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import "./layout.css";
import favicon from "$lib/assets/favicon.ico";
import { onMount } from 'svelte';
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);
}
});
import "./layout.css";
import favicon from "$lib/assets/favicon.ico";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
let { children } = $props();
onMount(() => {
// Inject analytics script
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} />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
<link rel="icon" href={favicon} />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
<meta name="description" content="A daily Bible game" />
</svelte:head>
{@render children()}
<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">
<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 pt-12 animate-fade-in-up"
>
<TitleAnimation />
<div class="font-normal"></div>
</h1>
{@render children()}
</div>

View File

@@ -8,22 +8,24 @@
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 ThemeToggle from "$lib/components/ThemeToggle.svelte";
import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
import { evaluateGuess, 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";
let { data }: PageProps = $props();
@@ -33,24 +35,6 @@
let user = $derived(data.user);
let session = $derived(data.session);
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);
const persistence = createGamePersistence(
() => dailyVerse.date,
() => dailyVerse.reference,
() => correctBookId,
() => user?.id,
);
let guessedIds = $derived(
new Set(persistence.guesses.map((g) => g.book.id)),
);
const currentDate = $derived(
new Date().toLocaleDateString("en-US", {
weekday: "long",
@@ -60,16 +44,29 @@
}),
);
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);
const persistence = createGamePersistence(
() => dailyVerse.date,
() => dailyVerse.reference,
() => correctBookId,
() => user,
);
let guessedIds = $derived(
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
);
let isWon = $derived(
persistence.guesses.some((g) => g.book.id === correctBookId),
);
let grade = $derived(
isWon
? persistence.guesses.length === 1 && persistence.chapterCorrect
? "S++"
: getGrade(persistence.guesses.length)
: "",
);
let blurChapter = $derived(
isWon &&
persistence.guesses.length === 1 &&
@@ -90,6 +87,7 @@
(window as any).umami
) {
(window as any).umami.track("First guess");
(window as any).rybbit?.event("First guess");
localStorage.setItem(key, "true");
}
}
@@ -114,31 +112,6 @@
}
}
// If server date doesn't match client's local date, fetch timezone-correct verse
$effect(() => {
if (!browser) return;
const localDate = new Date().toLocaleDateString("en-CA");
if (dailyVerse.date === localDate) return;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
fetch("/api/daily-verse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ date: localDate, timezone }),
})
.then((res) => res.json())
.then((result) => {
dailyVerse = result.dailyVerse;
correctBookId = result.correctBookId;
correctBook = result.correctBook;
})
.catch((err) =>
console.error("Failed to fetch timezone-correct verse:", err),
);
});
// Reload when the user returns to a stale tab on a new calendar day
$effect(() => {
if (!browser) return;
@@ -238,19 +211,36 @@
(window as any).umami.track("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
(window as any).rybbit?.event("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
}
});
// 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;
});
}
});
});
function getShareText(): string {
return generateShareText({
guesses: persistence.guesses,
correctBookId,
dailyVerseDate: dailyVerse.date,
grade,
chapterCorrect: persistence.chapterCorrect,
isLoggedIn: !!user,
userStreak: user ? (user as any).streak : undefined,
streak,
origin: window.location.origin,
verseText: dailyVerse.verseText,
});
}
@@ -293,20 +283,13 @@
<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="pb-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 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>
<div class="flex flex-col gap-6">
<div class="animate-fade-in-up animate-delay-200">
<VerseDisplay {data} {isWon} {blurChapter} />
@@ -314,12 +297,16 @@
{#if !isWon}
<div class="animate-fade-in-up animate-delay-400">
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
<SearchInput
bind:searchQuery
{guessedIds}
{submitGuess}
guessCount={persistence.guesses.length}
/>
</div>
{:else if showWinScreen}
<div class="animate-fade-in-up animate-delay-400">
<WinScreen
{grade}
{statsData}
{correctBookId}
{handleShare}
@@ -329,6 +316,12 @@
guessCount={persistence.guesses.length}
reference={dailyVerse.reference}
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
shareText={getShareText()}
verseText={dailyVerse.verseText}
{streak}
{streakPercentile}
isLoggedIn={!!user}
anonymousId={persistence.anonymousId}
/>
</div>
{/if}
@@ -342,47 +335,16 @@
<Credits />
</div>
{/if}
<!-- We will just go with the user's system color theme for now. -->
<div class="flex justify-center hidden mt-4">
<ThemeToggle />
</div>
</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 bg-gray-100 px-3 py-2 rounded border"
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>
@@ -412,19 +374,33 @@
)}
</div>
<div>Daily Verse Date: {dailyVerse.date}</div>
<div>Streak: {streak}</div>
</div>
<DevButtons />
<DevButtons anonymousId={persistence.anonymousId} {user} onSignIn={() => (authModalOpen = true)} />
</div>
{/if}
{#if user && session}
<div class="mt-6 pt-4 border-t border-gray-200 text-center text-xs text-gray-400">
Signed in as {[user.firstName, user.lastName].filter(Boolean).join(" ")}{user.email ? ` (${user.email})` : ""}{user.appleId ? " using Apple" : ""}
<form method="POST" action="/auth/logout" use:enhance class="inline">
<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>
>Sign out</button
>
</form>
</div>
{/if}

View File

@@ -0,0 +1,13 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { marked } from 'marked';
export async function load() {
const about = readFileSync(resolve('static/about.md'), 'utf-8');
const howToPlay = readFileSync(resolve('static/how-to-play.md'), 'utf-8');
return {
about: await marked(about),
howToPlay: await marked(howToPlay)
};
}

View File

@@ -0,0 +1,48 @@
<svelte:head>
<title>About — Bibdle</title>
</svelte:head>
<script lang="ts">
import SocialLinks from "$lib/components/SocialLinks.svelte";
let { data } = $props();
const SOCIAL_PLACEHOLDER = "<!-- social -->";
const aboutParts = $derived(
data.about.includes(SOCIAL_PLACEHOLDER)
? data.about.split(SOCIAL_PLACEHOLDER)
: null
);
</script>
<div class="min-h-dvh py-10 px-4">
<div class="w-full max-w-xl mx-auto">
<div class="mb-8">
<a
href="/"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
>
← Back to Game
</a>
</div>
<div class="prose dark:prose-invert text-justify max-w-none">
{#if aboutParts}
{@html aboutParts[0]}
<div class="my-8 not-prose">
<SocialLinks />
</div>
{@html aboutParts[1]}
{:else}
{@html data.about}
{/if}
</div>
<div class="prose dark:prose-invert text-justify max-w-none mt-10">
{@html data.howToPlay}
</div>
</div>
</div>

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,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,42 @@
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 (stored as the user's local date)
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));
// Subtract one calendar day from a YYYY-MM-DD string using UTC arithmetic —
// this avoids any dependence on the server's local timezone or DST offsets.
function prevDay(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
// Walk backwards from the user's local date, counting consecutive completed days
let streak = 0;
let cursor = localDate;
while (completedDates.has(cursor)) {
streak++;
cursor = prevDay(cursor);
}
return json({ streak: streak < 2 ? 0 : streak });
};

View File

@@ -1,7 +1,6 @@
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 }) => {
@@ -10,7 +9,7 @@ export const actions: Actions = {
// Generate CSRF state
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
const state = encodeBase64url(stateBytes);
const state = Buffer.from(stateBytes).toString('base64url');
// Store state + anonymousId in a short-lived cookie
// sameSite 'none' + secure required because Apple POSTs cross-origin

View File

@@ -25,6 +25,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
}
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);
@@ -51,7 +57,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (existingAppleUser) {
userId = existingAppleUser.id;
await auth.migrateAnonymousStats(stored.anonymousId, userId);
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);
@@ -59,10 +66,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Link Apple account to existing user
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
userId = existingEmailUser.id;
await auth.migrateAnonymousStats(stored.anonymousId, userId);
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 = stored.anonymousId || crypto.randomUUID();
userId = anonId;
console.log(`[Apple auth] New user (has email): userId=${userId}`);
try {
await db.insert(userTable).values({
id: userId,
@@ -79,6 +88,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
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');
}
@@ -89,7 +100,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
}
} else {
// No email from Apple — create account with appleId only
userId = stored.anonymousId || crypto.randomUUID();
userId = anonId;
console.log(`[Apple auth] New user (no email): userId=${userId}`);
try {
await db.insert(userTable).values({
id: userId,
@@ -105,6 +117,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
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');
}

View File

@@ -1,25 +1,36 @@
<script lang="ts">
import { page } from '$app/state';
import AuthModal from '$lib/components/AuthModal.svelte';
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);
const anonymousId = crypto.randomUUID();
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>
<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">
<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}
onclick={() => (isOpen = true)}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Open Auth Modal

View File

@@ -2,12 +2,29 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-triodion: "PT Serif", serif;
}
html, body {
background: oklch(89.126% 0.06134 298.626);
transition: background 0.3s ease;
}
@media (prefers-color-scheme: dark) {
html:not(.light), body:not(.light) {
background: oklch(18% 0.03 298.626);
}
}
html.dark, html.dark body {
background: oklch(18% 0.03 298.626);
}
html.light, html.light body {
background: oklch(89.126% 0.06134 298.626);
}
.big-text {
@@ -18,6 +35,20 @@ html, body {
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
html:not(.light) .big-text {
color: rgb(156 163 175);
}
}
html.dark .big-text {
color: rgb(156 163 175);
}
html.light .big-text {
color: rgb(107 114 128);
}
/* Page load animations */
@keyframes fadeInUp {
from {
@@ -48,4 +79,4 @@ html, body {
.animate-delay-800 {
animation-delay: 0.8s;
}
}

View File

@@ -0,0 +1,23 @@
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = () => {
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://bibdle.com/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://bibdle.com/about</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>`;
return new Response(sitemap, {
headers: {
'Content-Type': 'application/xml'
}
});
};

View File

@@ -27,9 +27,9 @@ export const load: PageServerLoad = async ({ url, locals }) => {
};
}
// 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 });
// Note: userToday is used only for the initial server-side streak estimate.
// The client overrides this with a precise local-date calculation via /api/streak.
const userToday = new Date().toISOString().slice(0, 10); // UTC date as safe fallback
try {
// Get all completions for this user
@@ -85,7 +85,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
};
// Calculate streaks
// Calculate streaks — dates are stored as the user's local date
const sortedDates = completions
.map((c: DailyCompletion) => c.date)
.sort();

View File

@@ -1,18 +1,14 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { enhance } from "$app/forms";
import AuthModal from "$lib/components/AuthModal.svelte";
import Container from "$lib/components/Container.svelte";
import { bibleBooks } from "$lib/types/bible";
import {
getGradeColor,
formatDate,
getStreakMessage,
getPerformanceMessage,
type UserStats,
} from "$lib/utils/stats";
import { fetchStreak } from "$lib/utils/streak";
interface PageData {
stats: UserStats | null;
@@ -24,8 +20,10 @@
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let anonymousId = $state("");
let loading = $state(true);
let currentStreak = $state(0);
function getOrCreateAnonymousId(): string {
if (!browser) return "";
@@ -39,13 +37,16 @@
}
onMount(async () => {
anonymousId = getOrCreateAnonymousId();
if (data.user?.id) {
const localDate = new Date().toLocaleDateString("en-CA");
currentStreak = await fetchStreak(data.user.id, localDate);
} else {
currentStreak = data.stats?.currentStreak ?? 0;
}
loading = false;
});
function getGradePercentage(count: number, total: number): number {
return total > 0 ? Math.round((count / total) * 100) : 0;
}
function getBookName(bookId: string): string {
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
}
@@ -158,7 +159,7 @@
<div
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
>
{stats.currentStreak}
{currentStreak}
</div>
<div
class="text-xs md:text-sm text-gray-300 font-medium"
@@ -331,85 +332,9 @@
</Container>
</div>
<!-- Grade Distribution -->
<Container class="p-5 md:p-6 mb-6">
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
Grade Distribution
</h2>
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
{@const percentage = getGradePercentage(
count,
stats.totalSolves,
)}
<div class="text-center">
<div class="mb-2">
<span
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
grade,
)}"
>
{grade}
</span>
</div>
<div
class="text-lg md:text-2xl font-bold text-gray-100"
>
{count}
</div>
<div class="text-xs text-gray-400">
{percentage}%
</div>
</div>
{/each}
</div>
</Container>
<!-- Recent Performance -->
{#if stats.recentCompletions.length > 0}
<Container class="p-5 md:p-6">
<h2
class="text-lg md:text-xl font-bold text-gray-100 mb-4"
>
Recent Performance
</h2>
<div class="space-y-2">
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
<div
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
>
<div>
<span
class="text-sm md:text-base font-medium text-gray-200"
>{formatDate(completion.date)}</span
>
</div>
<div
class="flex items-center gap-2 md:gap-3"
>
<span
class="text-xs md:text-sm text-gray-300"
>{completion.guessCount} guess{completion.guessCount ===
1
? ""
: "es"}</span
>
<span
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
completion.grade,
)}"
>
{completion.grade}
</span>
</div>
</div>
{/each}
</div>
</Container>
{/if}
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} anonymousId="" />
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />

15
static/about.md Normal file
View File

@@ -0,0 +1,15 @@
# About Bibdle
Bibdle is a daily Bible guessing game. Every day, a random verse is posted to the website. Try to figure out which book of the Bible it comes from, in as few guesses as possible. That's it!
---
The game was built with the hope that it would be a small, delightful thing people can make part of their day. It's not a Bible study course. You don't need to know the Bible inside and out to enjoy it or to learn something from it.
If you're someone who grew up in church and can name all 66 books in order, great. If you're someone who can barely tell the Old Testament from the New, that's great too. The game meets you where you are.
It is completely free. If you'd like to support the developer (who works solely on small projects like this one) or express your thanks, you can become a Bibdle patron.
If you use Bibdle, I would love to hear from you! I can be reached via email or through Bluesky.
<!-- social -->

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

47
static/how-to-play.md Normal file
View File

@@ -0,0 +1,47 @@
# How to Play
Each day, Bibdle gives you a verse from the Bible. Your job is to guess which book it comes from. (Genesis, John, Corinthians, etc.)
You have unlimited guesses.
---
## The Basics
1. **Read the verse.** It appears at the top of the page.
2. **Make a guess.** Type or select a book of the Bible from the list.
3. **Read the feedback.** After each guess, you'll get clues telling you how close you were.
4. **Keep guessing** until you get it right.
---
## Feedback Hints
After each wrong guess, you'll see the following hints:
| Hint | What it means |
|---|---|
| **Testament** | If your guess was in the correct Testament (Old or New) |
| **Section** | If your guess was in the correct section of the Bible (e.g. Gospels, Epistles, Major Prophets) |
| **First Letter** | If your guess has the same first letter as the correct guess |
Use the hints to narrow down your search.
---
## A Few Things to Know
- **Everyone plays the same verse each day.** The daily verse resets at midnight.
- **Your progress is saved automatically.** You can close the tab and come back later.
---
## Tips
- Pay attention to writing style: the voice of Psalms is very different from Paul's letters.
- Historical narrative (battles, kings, genealogies) tends to be Old Testament.
- Short, poetic, or wisdom-focused verses could be Proverbs, Ecclesiastes, or Psalms.
- If a verse mentions Jesus by name, it's in the New Testament.
Good luck!

View File

@@ -1,3 +1,5 @@
# allow crawling everything by default
User-agent: *
Disallow:
Sitemap: https://bibdle.com/sitemap.xml

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\.\.\./);
});
});

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

@@ -496,3 +496,149 @@ describe('Timezone-aware daily verse system', () => {
// See /api/submit-completion for the unique constraint enforcement
});
});
// ---------------------------------------------------------------------------
// Streak walk-back logic — mirrors /api/streak/+server.ts
// UTC is NEVER used for date comparison; all walk-back is pure string arithmetic.
// ---------------------------------------------------------------------------
function prevDay(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
function calcStreak(completedDates: Set<string>, localDate: string): number {
let streak = 0;
let cursor = localDate;
while (completedDates.has(cursor)) {
streak++;
cursor = prevDay(cursor);
}
return streak < 2 ? 0 : streak;
}
describe('Streak walk-back — local time always used, UTC never', () => {
test('prevDay uses UTC arithmetic, never server local time', () => {
// Regardless of server timezone, prevDay("2024-03-10") must always be "2024-03-09"
// (including across DST boundaries where local midnight ≠ UTC midnight)
expect(prevDay('2024-03-10')).toBe('2024-03-09'); // DST spring-forward in US
expect(prevDay('2024-11-03')).toBe('2024-11-02'); // DST fall-back in US
expect(prevDay('2024-01-01')).toBe('2023-12-31'); // year boundary
expect(prevDay('2024-03-01')).toBe('2024-02-29'); // leap year
expect(prevDay('2023-03-01')).toBe('2023-02-28'); // non-leap year
});
test('UTC+9 user: plays at 01:00 local (still previous UTC date) — streak uses local date', () => {
// Scenario: It is 2024-01-16 01:00 in Tokyo (UTC+9) = 2024-01-15 16:00 UTC.
// The verse served is for 2024-01-15 (UTC), but the user's LOCAL date is 2024-01-16.
// Completions are stored as the user's local date (as returned by dailyVerse.date
// which is set from the client's new Date().toLocaleDateString("en-CA")).
// The streak walk-back must use local dates, not UTC dates.
// Four consecutive local dates for a Tokyo user
const completedLocalDates = new Set(['2024-01-13', '2024-01-14', '2024-01-15', '2024-01-16']);
// If we (incorrectly) walked back from the UTC date "2024-01-15" instead of
// the local date "2024-01-16", we would miss the most recent completion.
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-15');
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-16');
// UTC walk-back misses the local "2024-01-16" entry → only finds 3 consecutive days
// (but actually it finds 2024-01-15, 2024-01-14, 2024-01-13 = 3, returned as 3)
// The point is it does NOT include 2024-01-16 which is "today" for the user.
expect(wrongStreakIfUTC).toBe(3); // stale — missing today's local entry
// Local walk-back correctly finds all four entries
expect(correctStreakWithLocalDate).toBe(4);
});
test('UTC-8 user: plays at 23:00 local (next UTC date) — streak uses local date', () => {
// Scenario: It is 2024-01-15 23:00 in Los Angeles (UTC-8) = 2024-01-16 07:00 UTC.
// The verse served is for 2024-01-16 (UTC), but the user's LOCAL date is still 2024-01-15.
// Completion is stored as local date "2024-01-15".
// Walk-back from "2024-01-15" (local) must find it; walk-back from "2024-01-16" (UTC)
// would NOT find it (the entry is stored as "2024-01-15").
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14', '2024-01-15']);
// If we (incorrectly) walked back from the UTC date "2024-01-16" instead of
// the local date "2024-01-15", "2024-01-16" is not in the set → streak = 0.
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-16');
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-15');
expect(wrongStreakIfUTC).toBe(0); // broken — UTC date not in DB
expect(correctStreakWithLocalDate).toBe(4);
});
test('UTC+13 user (Samoa): local date is two days ahead of UTC-11', () => {
// Extreme case: UTC+13 user on 2024-01-16 local = 2024-01-15 UTC.
const completedLocalDates = new Set(['2024-01-14', '2024-01-15', '2024-01-16']);
expect(calcStreak(completedLocalDates, '2024-01-14')).toBe(0); // only 1 day (< 2)
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(3); // correct local streak
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0); // UTC date misses Jan 16
});
test('streak is 0 when today is missing even if yesterday exists', () => {
// User missed today — streak must reset regardless of timezone
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14']);
// "Today" is 2024-01-16 — they missed the 15th and 16th
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(0);
});
test('streak suppressed below 2 — single day returns 0', () => {
const completedLocalDates = new Set(['2024-01-15']);
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0);
});
test('DB entries with local date store correctly alongside UTC completion timestamp', async () => {
// Verify DB round-trip: storing completions with local dates (not UTC)
// ensures streak calculation with local dates always works.
const userId = 'tz-test-user-' + crypto.randomUUID();
// UTC+9 scenario: played at 01:00 local on Jan 16 (= UTC Jan 15)
// Local date is Jan 16, stored as "2024-01-16" in the DB.
const completions = [
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-14', // local date
guessCount: 2,
completedAt: new Date('2024-01-13T15:00:00Z'), // UTC
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-15', // local date
guessCount: 3,
completedAt: new Date('2024-01-14T15:00:00Z'), // UTC
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-16', // local date — UTC equivalent is 2024-01-15
guessCount: 1,
completedAt: new Date('2024-01-15T16:00:00Z'), // 01:00 Tokyo time
},
];
await db.insert(dailyCompletions).values(completions);
const rows = await db
.select({ date: dailyCompletions.date })
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const storedDates = new Set(rows.map((r) => r.date));
// Walk-back from LOCAL date "2024-01-16" finds all three entries
expect(calcStreak(storedDates, '2024-01-16')).toBe(3);
// Walk-back from UTC date "2024-01-15" misses the "2024-01-16" local entry
// (demonstrates the bug that was fixed: UTC walk-back gives wrong result)
expect(calcStreak(storedDates, '2024-01-15')).toBe(0);
await db.delete(dailyCompletions).where(eq(dailyCompletions.anonymousId, userId));
});
});

57
todo.md
View File

@@ -59,10 +59,61 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done
## march 12th
- Added about page with social buttons and XML sitemap for SEO
- Fixed incorrect header background color on Desktop
- Added color theme toggle button (commented out for now)
## feb 26th
- Added dark mode
- Removed URL from share text (Wordle said it was ratchet)
- added option for sharing with verse snippet (hidden on share text first copy)
## february 22nd
- New share button design; speech bubbles
- Share rate jumped from ~17% to ~27% (n=200) after share button redesign
- Updated streak-percentile to count all players from last 30 days and all streaks (in case there are streaks >30 days)
- Added copy verse button
- Refactored book search input to show progressively more info based on guess count
## february 21st
- Added streak counter and streak percentage
- Added Rybbit analytics alongside Umami
## february 18th-19th
- Refactored game logic into utility modules
- Small fixes to Sign In with Apple migrations
## february 13th
- Added Sign In with Apple
- Added animations on win and guess
- Various Apple auth bug fixes
## february 11th-12th
- Client-side timezone handling for daily verses (was using server time)
- Staggered page load animations
- Reordered guesses table with emphasis
- Redesigned stats page with dark theme and enhanced statistics
## february 5th-10th
- Added login modal and auth infrastructure
- Switched to `bun:sqlite`
- Support authenticated users in stats and page loading
- Anonymous stats migration on sign-in
- Test infrastructure and sign-in migration tests
## february 2nd
- created rss feed
- fixed "first letter" clue edge cases
- fixed "first letter" clue edge cases / easter egg
- updated ranking formula
## january 28th
@@ -82,6 +133,10 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
- added "first letter" column
- added imposter mode, v0.1 (mom likes it) but needs work
## january 8th
- posted on Hacker News and LinkedIn, got 960 visitors in one day
## january 5th
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...