George Powell da29ee7196 feat(stats): quantile-scaled, annotated calendar heatmap
Replace the linear count/max alpha with quantile buckets (GitHub-style)
so a single outlier day no longer flattens the rest of the graph, and
render best/median/worst day labels inside their cells.

- Add buildCalendarScale utility with empty-bucket handling.
- Make CalendarHeatmap size, gap, and annotations configurable; align
  weeks to Monday.
- Expand the community calendar to 90 days, add play totals/averages and
  all-time best day, and format large numbers with locale separators.
2026-09-28 20:21:48 -04:00
2026-02-02 02:52:53 -05:00
2025-12-19 03:36:59 -05:00
2026-09-22 12:15:56 -04:00
2026-02-26 00:51:48 -05:00
2026-09-22 12:15:56 -04:00
2025-12-16 10:23:24 -05:00
2026-09-22 12:15:56 -04:00
2026-02-24 19:10:53 -05:00
2025-12-16 10:23:24 -05:00
2026-02-02 02:52:53 -05:00
2026-09-22 12:15:56 -04:00
2026-07-07 17:49:33 -04:00
2026-03-25 09:06:21 -04:00
2025-12-16 10:23:24 -05:00
2026-09-22 12:15:56 -04:00

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.

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

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

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

The statistics dashboard migration (drizzle/0004_slim_nomad.sql) is purely additive: it creates stats_snapshot, analytics_events and dashboard_settings, and adds a nullable user.created_at. It never alters or drops historical rows. To replicate to production, apply that SQL to prod.db (e.g. sqlite3 prod.db < drizzle/0004_slim_nomad.sql) before deploying the new code. Applying it twice is safe for the new tables only if you skip the existing objects; run it once.

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 and /api/cron/build-stats endpoints
  • DISCORD_DAILY_WEBHOOK — webhook for posting the daily verse to Discord
  • DISCORD_ADMIN_WEBHOOK — optional webhook for the daily statistics digest (digest is skipped when unset)
  • STATS_* — optional overrides for dashboard thresholds (STATS_D1_TARGET, STATS_CORE_PLAYS_PER_WEEK, …); see src/lib/types/stats.ts for defaults
  • 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, createdAt (nullable; existing accounts show "unknown")
  • 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.
  • statsSnapshot — one immutable JSON aggregate row per as-of day (date PK, generatedAt, buildKind, configVersion, payload, playerState, config). Powers /admin/stats with point-in-time reads.
  • analyticsEvents — generic event log (id, name, anonymousId, userId, date, createdAt, metadata) with a partial unique index deduping verse_served per player/day.
  • dashboardSettings — key/value overrides for dashboard thresholds.

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 Slim public community-stats widget: all-time finishes/players, weekly/monthly players, yesterday's finishes + average guesses, and a 30-day finish calendar. Reads the latest daily snapshot; no retention/churn/signup data.
/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.
/admin/stats Admin-only private Growth Console (gated to ADMIN_EMAIL). Once-daily, point-in-time snapshot; date navigator, “Mature this view”, editable thresholds, Pulse, Growth Loop, Retention, Verse Mix, Habit, Signals and Glossary. Set ?date=YYYY-MM-DD; ?mature=1 recomputes old cohorts using later data.

API Endpoints

Endpoint Description
POST /api/daily-verse Fetch (and cache) the verse for a given YYYY-MM-DD date. Also records a deduped verse_served event for the supplied anonymousId (started→finished funnel).
POST /api/events Fire-and-forget analytics sink. Allowlisted names: verse_served, share, share_copy, first_visit, donate_click, discord_click; userId resolved from the session. Always returns 204.
POST /api/cron/build-stats Cron-only (bearer CRON_SECRET). Builds the most recent snapshot whose global data window has closed (D+1 12:00Z) and posts the digest to DISCORD_ADMIN_WEBHOOK.
POST /api/admin/rebuild-stats Admin-only. Rebuilds one snapshot: { date: 'YYYY-MM-DD' }.
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, and stats-* (metrics, segments, settings, events, signals, snapshot, digest)
src/lib/server/stats/* Statistics engine: metrics.ts, segments.ts, snapshot.ts, signals.ts, settings.ts, events.ts, digest.ts, dates.ts.
src/lib/components/stats/* Growth Console UI (Pulse, Growth Loop, Retention/Verse Mix/Habit labs, Signals, Settings, Glossary).
src/lib/utils/analytics.ts Client event helper (track, anonymous ID, first-visit capture).

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.

S
Description
No description provided
Readme
22 MiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 60.1%
Svelte 38.7%
Shell 0.9%
CSS 0.2%