mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-08-22 22:32:28 -04:00
196 lines
14 KiB
Markdown
196 lines
14 KiB
Markdown
# Bibdle
|
||
|
||
A daily Bible verse guessing game. Each day a verse is shown and players try to identify which of the 66 books of the Bible it comes from, receiving Wordle-style feedback (Testament match, Section match, Adjacent book, First letter, etc.) after each guess. A new verse is generated daily and progress is tracked across timezones.
|
||
|
||
Live at [bibdle.com](https://bibdle.com).
|
||
|
||
## Tech Stack
|
||
|
||
- **Framework**: SvelteKit 2 with Svelte 5 (runes: `$state`, `$derived`, `$effect`, `$props`)
|
||
- **Styling**: Tailwind CSS 4
|
||
- **Database**: SQLite (`bun:sqlite`) with Drizzle ORM
|
||
- **Auth**: Session-based — email/password (argon2id via `Bun.password`), plus Apple and Google OAuth
|
||
- **Bible text**: Local NKJV XML (`EnglishNKJBible.xml`), parsed with `fast-xml-parser`; a Greek 1904 and Swedish 2000 translation are also bundled for alternate modes
|
||
- **ML** (currently disabled): `@xenova/transformers` verse embeddings for a similarity search route
|
||
- **Deployment**: `@sveltejs/adapter-node`, run under Bun, managed by a systemd service (see `bibdle.service`)
|
||
|
||
## Getting Started
|
||
|
||
```bash
|
||
bun install
|
||
|
||
# Start the dev server (Vite, Bun runtime)
|
||
bun run dev
|
||
|
||
# Type checking
|
||
bun run check
|
||
|
||
# Tests (Bun test)
|
||
bun test
|
||
bun test tests/timezone-handling.test.ts # single file
|
||
bun test --watch
|
||
|
||
# Production build & preview
|
||
bun run build
|
||
bun run preview
|
||
```
|
||
|
||
### Database
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
### Environment Variables
|
||
|
||
See `.env.example`. Required/used variables:
|
||
|
||
- `DATABASE_URL` — path to the SQLite database file (e.g. `prod.db`)
|
||
- `PUBLIC_SITE_URL` — canonical site URL
|
||
- `CRON_SECRET` — bearer token protecting the cron-only `/api/send-daily-verse` endpoint
|
||
- `DISCORD_DAILY_WEBHOOK` — webhook for posting the daily verse to Discord
|
||
- `AUTH_SECRET` — secret for Apple Sign-In
|
||
- `APPLE_ID` / `APPLE_TEAM_ID` / `APPLE_KEY_ID` / `APPLE_PRIVATE_KEY` — Apple Sign-In credentials
|
||
- `SMTP_USERNAME` / `SMTP_TOKEN` / `SMTP_SERVER` / `SMTP_PORT` — email (nodemailer)
|
||
|
||
## Architecture
|
||
|
||
### Database Schema (`src/lib/server/db/schema.ts`)
|
||
|
||
- **user** — `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `googleId` (unique), `isPrivate`
|
||
- **session** — `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
||
- **dailyVerses** — cached daily verse: `date` (unique), `bookId`, `verseText`, `reference`, `createdAt`
|
||
- **dailyCompletions** — one row per player/date: `anonymousId`, `date`, `guessCount`, `guesses` (JSON of book IDs, nullable), `completedAt`. Unique on `(anonymousId, date)` to prevent duplicate submissions.
|
||
- **verseSubmissions** — a log of community-submitted future verses: `id`, `userId` (FK → `user.id` ON DELETE SET NULL), `scheduledDate` (unique `YYYY-MM-DD` matching a `dailyVerses` row), `selectedBookId`, `selectedChapter`, `selectedVerse` (the anchor the user picked), `submittedAt` (server UTC millis, drives the 7-day cooldown). Indexed on `userId` (cooldown lookup) and `scheduledDate` (unique). The canonical verse text/reference lives on `dailyVerses`, joined by `scheduledDate`.
|
||
|
||
Sessions expire after 30 days and auto-renew when fewer than 15 days remain.
|
||
|
||
### Bible Data (`src/lib/types/bible.ts`)
|
||
|
||
The `bibleBooks` array lists all 66 books with metadata:
|
||
|
||
- `testament`: `old` | `new`
|
||
- `section`: `Law`, `History`, `Wisdom`, `Major Prophets`, `Minor Prophets`, `Gospels`, `Pauline Epistles`, `General Epistles`, `Apocalyptic`
|
||
- `order` (1–66, used for adjacency detection)
|
||
|
||
### Game Logic
|
||
|
||
- `src/lib/utils/game.ts` — `evaluateGuess()` compares a guess to the target book and returns `testamentMatch`, `sectionMatch`, `adjacent`, and `firstLetterMatch` flags. `getGrade()` maps guess count to a letter grade (S+ down to C). Includes a special-case so that numbered Epistles (e.g. "1 John") match on first letter against any other numbered Epistle.
|
||
- `src/lib/stores/game-persistence.svelte.ts` — reactive store that keeps guesses and per-day flags in sync with `localStorage`, keyed by date (`bibdle-guesses-${date}`). Resolves the player identity (logged-in user ID, or a locally generated anonymous UUID) and restores state.
|
||
- `src/lib/utils/share.ts` — generates the share grid and text. Hint emojis: ✅ exact · 🟩 section · 🟧 testament · ‼️ adjacent · 🟥 no match.
|
||
|
||
### Daily Verse System
|
||
|
||
`src/lib/server/daily-verse.ts` → `getVerseForDate(date)`: returns the cached verse for a date if present, otherwise fetches a random verse from the local XML Bible and stores it permanently. The XML Bible is read and parsed in `src/lib/server/xml-bible.ts`; `src/lib/server/bible-api.ts` wraps it to produce a verse with a validated `bookId`, `reference`, and `verseText`.
|
||
|
||
### Community Verse Submissions
|
||
|
||
Authenticated users who have solved today's puzzle can submit a verse for scheduling as a future "verse of the day." Selection is dropdown-based (cascading Book → Chapter → Verse selects), so content is always canonical NKJV text — no free-text entry. Each submission immediately reserves a concrete future date.
|
||
|
||
- `src/lib/server/verse-submission.ts` — the 3-verse window composer (`composeVerseWindow`, fall-forward, never crosses a book), `formatWindowReference` (same-chapter hyphen / cross-chapter en-dash), and the assign-at-submit scheduling scan (empty / no-back-to-back-with-committed-neighbors / 60-day-repeat rules, transaction + retry-on-unique-conflict).
|
||
- `getVerseForDate(date)` serves pre-written submission rows unchanged on their day; gap days (no committed row) are filled lazily by the random path, now extended to avoid the book of any committed neighbor (D-1 / D+1).
|
||
- **Rate limiting:** one submission per user per rolling 7×24h window, measured in server UTC (not gameable). The win-screen button is always visible; it is greyed out with a countdown timer while the cooldown is active.
|
||
- **Attribution is anonymous everywhere** except the admin-only `/scheduled-verses` view (the single exception, gated to `ADMIN_EMAIL` in `src/lib/server/admin.ts`), which surfaces submitter email for moderation.
|
||
|
||
### Authentication (`src/lib/server/auth.ts`)
|
||
|
||
- Token: base64url-encoded random bytes; stored as a SHA-256 hash in the DB. Cookie name: `auth-session`.
|
||
- Anonymous users are identified by a client-generated UUID in `localStorage`. On sign-up, `migrateAnonymousStats()` re-attributes the user's `dailyCompletions` rows from the anonymous ID to the new user ID (duplicates for overlapping dates are dropped).
|
||
- Apple Sign-In (`src/lib/server/apple-auth.ts`) and Google Sign-In (`src/lib/server/google-auth.ts`) are supported; the SvelteKit CSRF config trusts `https://appleid.apple.com` for the cross-origin `form_post` callback.
|
||
|
||
## Routes
|
||
|
||
### Pages
|
||
|
||
| Route | Description |
|
||
| --- | --- |
|
||
| `/` | Main game (classic mode). Verse display, search input, guesses table, win screen, streak/percentile. `+page.ts` sets `ssr = false` so the load runs client-side with the true local date, then POSTs that date to `/api/daily-verse`. |
|
||
| `/imposter` | Imposter Mode — four verses are shown; three come from one book and one from a different book. Pick the one that doesn't belong. |
|
||
| `/random` | Debug page showing a random verse from the NKJV. |
|
||
| `/greek-random` | Debug page showing a random verse in parallel Greek (1904) / English (NKJV). |
|
||
| `/similarity` | Search a sentence and return the most similar Bible verses via the (currently disabled) embeddings model. |
|
||
| `/about` | About page with the project's backstory and social links. |
|
||
| `/global` | Public stats dashboard: completions today/all-time, unique & weekly & monthly players, active streak distribution, 14-day completions trend, retention/return-rate metrics. |
|
||
| `/progress` | Personal progress page (requires auth): activity calendar, 66-book grid with mastery tiers, insights, and achievements/milestones. |
|
||
| `/stats` | Personal stats page (requires auth); returns `requiresAuth: true` for unauthenticated visitors and renders a sign-in modal. |
|
||
| `/dev` | Local-time / countdown debug page. |
|
||
| `/scheduled-verses` | Admin-only (gated to `ADMIN_EMAIL` in `src/lib/server/admin.ts`). Full historical + future log of `verseSubmissions` joined to `dailyVerses` and `user`, sorted by scheduled date. The sole surface where submitter identity is shown. Not linked from the UI. |
|
||
|
||
### API Endpoints
|
||
|
||
| Endpoint | Description |
|
||
| --- | --- |
|
||
| `POST /api/daily-verse` | Fetch (and cache) the verse for a given `YYYY-MM-DD` date. |
|
||
| `POST /api/submit-completion` | Submit a game result (`anonymousId`, `date`, `guessCount`, `guesses`); returns solve rank, guess rank, total solves, average guesses, ties, and percentile. Unique on `(anonymousId, date)`. |
|
||
| `GET /api/streak?anonymousId=X&localDate=Y` | Current streak: walks backwards from the client's local date through completed dates, counting consecutive days. Single-day streaks are reported as 0 (minimum displayed streak is 2). |
|
||
| `GET /api/streak-percentile?streak=N&localDate=Y` | Streak percentile ranking computed across all players' current streaks. |
|
||
| `GET /api/stats` | Aggregated stats used by the `/global` dashboard. |
|
||
| `GET /api/imposter` | Generates a four-verse imposter-mode round. |
|
||
| `POST /api/similar-verses` | Semantic verse search via embeddings. |
|
||
| `POST /api/send-daily-verse` | Cron-only (bearer `CRON_SECRET`); posts today's verse to the Discord webhook. |
|
||
| `POST /api/dev/seed-history` | Dev seeding helper. |
|
||
| `POST /api/submit-verse` | Auth required. Accept `{ bookId, chapter, verse, localDate }`; runs the solved-today gate, 7-day cooldown, structural validation, and the assign-at-submit scheduling scan; returns `{ scheduledDate, reference, windowText }`. |
|
||
| `GET /api/submit-verse/status?localDate=YYYY-MM-DD` | Auth required. Win-screen button state: `canSubmit`, `cooldownEndsAt`, `lastSubmission`, and this user's not-yet-reached upcoming submissions. |
|
||
| `GET /api/bible/structure` | Public. 66-book verse counts per chapter (cascading-dropdown payload, cached indefinitely). |
|
||
| `GET /api/verse-window?bookId=gen&chapter=1&verse=1` | Public. Live 3-verse preview (fall-forward window) for the submit selector. |
|
||
|
||
### Other Endpoints
|
||
|
||
- `GET /feed.xml` — RSS feed of daily verses.
|
||
- `GET /sitemap.xml` — XML sitemap for SEO.
|
||
|
||
## Critical: Date/Time Handling
|
||
|
||
Bibdle is played across many timezones. The verse shown must always be the verse for the calendar date at **the player's location** — never the server's timezone, never UTC.
|
||
|
||
- The client computes today's date with `new Date().toLocaleDateString("en-CA")` (`YYYY-MM-DD`) and sends it to the server.
|
||
- Server-side date arithmetic always uses UTC methods on the client-provided date string (`new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`) to avoid timezone drift — see `/api/streak` and `/api/send-daily-verse`.
|
||
- `/` has `ssr = false` so the load runs client-side with the real local date.
|
||
- The main page also reloads itself if the tab regains focus on a new calendar day.
|
||
- The user's local date is never placed in the URL; it is only ever sent to API routes.
|
||
|
||
### Streak Calculation
|
||
|
||
A streak counts consecutive calendar days (in the player's local timezone) on which the puzzle was completed:
|
||
|
||
- The client passes `localDate`; the server never uses its own clock.
|
||
- `/api/streak` walks backwards from `localDate` through `dailyCompletions`, counting each completed day; stops at the first missing day. It's called from the win screen, so today is typically completed. Single-day streaks are reported as `0` — the minimum displayed streak is 2.
|
||
- `/api/streak-percentile` (which ranks all players) anchors on today-if-played-else-yesterday, so a player's streak isn't zeroed mid-day before they've had a chance to complete today's puzzle.
|
||
|
||
## Key Files
|
||
|
||
| File | Purpose |
|
||
| --- | --- |
|
||
| `src/routes/+page.svelte` | Main game UI and client-side logic |
|
||
| `src/routes/+page.server.ts` / `+page.ts` | Server load (user/session) + client load (`ssr: false`, fetches the daily verse) |
|
||
| `src/routes/+layout.svelte` | App shell, title animation, theme toggle, analytics injection |
|
||
| `src/lib/server/auth.ts` | Session management, password hashing, anonymous→user migration |
|
||
| `src/lib/server/apple-auth.ts`, `google-auth.ts` | OAuth providers |
|
||
| `src/lib/server/daily-verse.ts` | Per-date verse caching/lookup |
|
||
| `src/lib/server/xml-bible.ts` | Local XML Bible parsing (NKJV / Greek / Swedish) |
|
||
| `src/lib/server/bible-api.ts` | Random verse fetching on top of the XML parser |
|
||
| `src/lib/server/bible.ts` | Bible book utility functions |
|
||
| `src/lib/server/milestones.ts` | Achievement/milestone calculation (set-completion, streak, etc.) |
|
||
| `src/lib/server/admin.ts` | `ADMIN_EMAIL` constant for the `/scheduled-verses` admin route. |
|
||
| `src/lib/server/verse-submission.ts` | Window composer + scheduling scan for community verse submissions. |
|
||
| `src/lib/components/SubmitVerse.svelte` | Win-screen submit button (logged-out sign-in dropdown / cooldown + countdown / cascading selects + preview + submit). |
|
||
| `src/lib/types/bible.ts` | 66-book metadata and TypeScript types |
|
||
| `src/lib/utils/game.ts` | Guess evaluation and grading |
|
||
| `src/lib/utils/share.ts` | Share grid/text generation |
|
||
| `src/lib/utils/streak.ts`, `stats-client.ts`, `stats.ts` | Client-side streak/stats fetching and formatting |
|
||
| `src/lib/stores/game-persistence.svelte.ts` | Reactive localStorage-backed game state |
|
||
| `src/lib/server/db/schema.ts` | Drizzle ORM schema |
|
||
| `src/hooks.server.ts` | Session validation hook; (commented-out) embeddings init |
|
||
| `tests/` | Bun test suites: timezone, game, bible, stats, share, sign-in migration |
|
||
|
||
## Deployment
|
||
|
||
Production uses `@sveltejs/adapter-node` run under Bun via a systemd service. `deploy.sh` pulls latest, installs deps, builds, and restarts `bibdle.service` (which runs `bun --bun build/index.js` on port 5173 with `DATABASE_URL=prod.db`). See `bibdle.service` for the unit file.
|
||
|
||
## Background
|
||
|
||
Bibdle was created as a small, daily nudge to read the Bible — inspired by the Wordle story of a personal project that grew organically through word of mouth. The full backstory lives on the `/about` page.
|