29 Commits

Author SHA1 Message Date
George Powell 4a53e09ab3 documentation 2026-07-07 17:49:33 -04:00
George Powell 2fb484ebaa Minor button cooldown change 2026-07-07 17:06:12 -04:00
George Powell 21efdd4eef New scheduling UI! 2026-07-07 17:02:24 -04:00
George Powell be0d7ad297 Added verse-submission infrastructure 2026-07-07 15:41:10 -04:00
George Powell 99552c57ad Added donation button 2026-07-07 13:12:17 -04:00
George Powell efc9900de1 Updated documentation 2026-07-07 12:13:35 -04:00
George Powell e3ca264c54 new email link 2026-05-26 17:59:56 -04:00
George Powell f5e16c7e71 updated todo and fixed themetoggle 2026-03-25 09:06:21 -04:00
George Powell e45ac28169 rainbow glow 2026-03-25 02:25:04 -04:00
George Powell 3d578a9eb8 feat: add Google sign-in button to WinScreen and footer provider label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 01:50:34 -04:00
George Powell db04da6a2c feat: add Sign In with Google
Adds Google OAuth alongside existing Apple and email/password auth. Follows the same patterns as Apple Sign-In: state cookie for CSRF, anonymousId migration, and user linking by email. Key differences: Google callback is a GET redirect (sameSite: lax) and uses a static client secret instead of a signed JWT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 01:39:24 -04:00
George Powell 321fac9aa8 feat: add achievements system, hint overlay, and progress page polish
Achievements system:
- Add src/lib/server/milestones.ts with full achievement definitions and
  calculation logic (16 achievements: streaks, book set completions,
  community milestones like Overachiever/Procrastinator/Outlier, and fun
  ones like Prodigal Son, Extra Credit, Is This A Joke To You?)
- Wire calculateMilestones() into the progress page server load
- Replace the old ad-hoc milestone cards with a proper achievements grid
  (3/4 col, uniform min-height cards, larger text)
- Change "With God, All Things Are Possible" from "every game solved in 1"
  to "solve in 1 guess for each of the 66 books at least once"

Game page hint overlay:
- After a correct testament/section/first-letter match, display a subtle
  text hint below the verse prompt (e.g. "It is in the Old Testament.")
- Hints fade in 2.8s after a guess (after the row flip animation)
- Hints are only shown to new players (fewer than 3 tracked wins) to
  avoid being patronising to experienced players

Progress page:
- Hide Skill Growth chart with {#if false && showChart} pending rework
- Fix book tier colour scheme: explored=blue, mastered=purple, perfect=emerald
  (was amber/emerald — now consistent across grid, legend, and stat cards)
- Simplify GuessesTable row colour: remove proximity gradient, use flat red
  for wrong guesses
- Add "Come back tomorrow!" encouragement text in CountdownTimer for new
  players (fewer than 3 wins)
- Fix GamePrompt text colour to always be gray-100

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:37:15 -04:00
George Powell 4a5aef5a3d refactor: extract CollapsibleTable component and fix show more
Replaces 7 inline collapsible tables in the global stats page with a
reusable CollapsibleTable component. Adds mode tab toggle (Rolling 30d /
Calendar) into the component. Fixes show more/less which was broken due
to mode-based expanded tracking when no modes were provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:17:30 -04:00
George Powell f98ab24d2e fix: update Discord message format to italic date + bold verse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 01:05:38 -04:00
George Powell c5b333bbb3 fix: use Bun.env instead of $env/dynamic/private to avoid build-time errors
$env/dynamic/private requires env vars to be present at build time.
Bun.env reads them at runtime, which is correct for runtime secrets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:59:16 -04:00
George Powell e842923d81 guesses 2026-03-22 00:58:17 -04:00
George Powell 51bfb53a39 feat: add /api/send-daily-verse endpoint for daily Discord verse posting
Protected by CRON_SECRET bearer token. Fetches today's verse in
America/New_York timezone and POSTs it to DISCORD_DAILY_WEBHOOK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:57:01 -04:00
George Powell 45d33b6bad feat: improve guesses collapse timing, win screen CTA, and progress page polish
- GuessesTable now accepts a `minimized` prop instead of deriving collapse from `isWon`, giving the parent control over timing
- Delay collapsing guesses grid until win animations complete (1800ms), skipped for already-completed puzzles
- Replace plain progress link on win screen with a styled green button matching other CTAs
- Progress page: remove redundant subtitle and nav button from header, add book status legend, add axis labels to guess history chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:47:02 -04:00
George Powell 3eb3a968dc feat: add progress page with activity calendar, book grid, and insights
Adds a new /progress route showing a personalized Bible knowledge dashboard
with stat cards, book mastery grid, 30-day activity calendar, skill growth
chart, streak milestones, and section insights. Links added from WinScreen
(logged-in users) and DevButtons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:33:47 -04:00
George Powell 67d9757f98 added discord link and shrunk guessesgrid for more than three guesses 2026-03-19 10:53:57 -04:00
George Powell b6b41b6ba9 added MaU section with projection 2026-03-19 00:39:54 -04:00
George Powell bdc08bc58e Added survival curve metrics and table minimizing 2026-03-19 00:18:54 -04:00
George Powell 83cfcc66c0 feat: add WAU history table, fix retention metric, add new logos and favicon
- Add 12-week Weekly Active Users table to global stats with WoW change %
- Fix 7-day and 30-day retention to measure return on exactly day N (not any day within the window)
- Remove "Avg Guesses Today" stat card
- Update retention description to clarify exact-day measurement
- Add bibdle logos (SVG, square PNG, circle PNG) and new favicon.png
- Wire favicon.png as the site favicon via app.html link tag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:04:45 -04:00
George Powell e878dea235 Fixed instructions, added color border based on closeness between guess
and target
2026-03-15 03:19:21 -04:00
George Powell 252edc3a6d fix: show most recent dates first in return rate and retention tables
- Reverse new player return rate table (most recent day at top)
- Reverse 7- and 30-day retention tables (most recent cohort at top)
- Rename "Day Rate" column to "Return Rate"
- Clarify "Last 14 Days" heading to "Last 14 Days — Completions"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 02:16:16 -04:00
George Powell 75b13280ef feat: add return rate and retention metrics to global stats
- Overall return rate: % of all-time players who played more than once
- New player return rate: 7-day rolling avg of daily first-timer return rates, with velocity vs prior 7 days
- 7-day and 30-day retention over time: per-cohort-day retention series

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 02:09:55 -04:00
George Powell 7007df2966 added global route for stat tracking 2026-03-14 22:17:33 -04:00
George Powell 61673a646d Added thank you message 2026-03-14 18:46:22 -04:00
George Powell 1eb8eb2f04 Added umami event to Apple signin and fixed spacing 2026-03-14 18:40:57 -04:00
63 changed files with 9541 additions and 580 deletions
+6
View File
@@ -1,5 +1,11 @@
DATABASE_URL=example.db DATABASE_URL=example.db
# Cron job secret for protected endpoints (e.g. send-daily-verse)
CRON_SECRET=your-cron-secret-here
# Discord webhook URL for posting the daily verse
DISCORD_DAILY_WEBHOOK=https://discord.com/api/webhooks/your-webhook-url
PUBLIC_SITE_URL=https://bibdle.com PUBLIC_SITE_URL=https://bibdle.com
# nodemailer # nodemailer
-165
View File
@@ -1,165 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book, etc.) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
(Make sure you use the Svelte agent to execute these commands)
## Available MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
## 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 Bun's built-in cryptographically secure functions
- **Deployment**: Node.js adapter for production builds
- **ML**: `@xenova/transformers` for verse embeddings (initialized in server hook) (currently disabled, was a test for a cancelled project)
## Development Commands
```bash
# Start development server
bun run dev
# Type checking
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
# Preview production build
bun run preview
# Database operations
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**: `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 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)
- Order (1-66, used for adjacency detection)
### Daily Verse System (`src/routes/+page.server.ts`)
`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 stored in localStorage keyed by date: `bibdle-guesses-${date}`
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
- `isWon` derived from whether any guess matches the correct book
**Hint System, for share grid:**
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
### Authentication System (`src/lib/server/auth.ts`)
- 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` / `+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`)
## Deployment
Uses `@sveltejs/adapter-node`. See `bibdle.service` systemd configuration.
## A Note
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.
+180 -23
View File
@@ -1,38 +1,195 @@
# sv # Bibdle
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 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.
## Creating a project Live at [bibdle.com](https://bibdle.com).
If you're seeing this, you've probably already done this step. Congrats! ## Tech Stack
```sh - **Framework**: SvelteKit 2 with Svelte 5 (runes: `$state`, `$derived`, `$effect`, `$props`)
# create a new project in the current directory - **Styling**: Tailwind CSS 4
bunx sv create - **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`)
# create a new project in my-app ## Getting Started
bunx sv create my-app
```
## Developing ```bash
bun install
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: # Start the dev server (Vite, Bun runtime)
```sh
bun run dev bun run dev
# or start the server and open the app in a new browser tab # Type checking
bun run dev -- --open bun run check
```
## Building # Tests (Bun test)
bun test
bun test tests/timezone-handling.test.ts # single file
bun test --watch
To create a production version of your app: # Production build & preview
```sh
bun run build bun run build
bun run preview
``` ```
You can preview the production build with `bun run preview`. ### Database
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ```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` (166, 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.
+385
View File
@@ -0,0 +1,385 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="680"
viewBox="0 0 680 520"
version="1.1"
id="svg41"
sodipodi:docname="bibdle_logo.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview41"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="true"
inkscape:zoom="0.91550428"
inkscape:cx="321.68064"
inkscape:cy="144.18283"
inkscape:window-width="1512"
inkscape:window-height="921"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="svg41">
<inkscape:grid
id="grid41"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs32">
<linearGradient
id="bgSq"
x1="0"
y1="0"
x2="0"
y2="1">
<stop
offset="0%"
stop-color="rgb(110,154,202)"
id="stop1" />
<stop
offset="3.23%"
stop-color="rgb(111,155,203)"
id="stop2" />
<stop
offset="6.45%"
stop-color="rgb(112,156,203)"
id="stop3" />
<stop
offset="9.68%"
stop-color="rgb(114,158,204)"
id="stop4" />
<stop
offset="12.9%"
stop-color="rgb(115,159,205)"
id="stop5" />
<stop
offset="16.13%"
stop-color="rgb(116,160,205)"
id="stop6" />
<stop
offset="19.35%"
stop-color="rgb(118,162,206)"
id="stop7" />
<stop
offset="22.58%"
stop-color="rgb(119,163,207)"
id="stop8" />
<stop
offset="25.81%"
stop-color="rgb(121,165,208)"
id="stop9" />
<stop
offset="29.03%"
stop-color="rgb(123,167,209)"
id="stop10" />
<stop
offset="32.26%"
stop-color="rgb(125,168,210)"
id="stop11" />
<stop
offset="35.48%"
stop-color="rgb(127,170,211)"
id="stop12" />
<stop
offset="38.71%"
stop-color="rgb(130,172,212)"
id="stop13" />
<stop
offset="41.94%"
stop-color="rgb(132,175,213)"
id="stop14" />
<stop
offset="45.16%"
stop-color="rgb(135,177,214)"
id="stop15" />
<stop
offset="48.39%"
stop-color="rgb(138,180,215)"
id="stop16" />
<stop
offset="51.61%"
stop-color="rgb(141,182,216)"
id="stop17" />
<stop
offset="54.84%"
stop-color="rgb(145,185,218)"
id="stop18" />
<stop
offset="58.06%"
stop-color="rgb(149,188,219)"
id="stop19" />
<stop
offset="61.29%"
stop-color="rgb(153,191,220)"
id="stop20" />
<stop
offset="64.52%"
stop-color="rgb(158,195,222)"
id="stop21" />
<stop
offset="67.74%"
stop-color="rgb(163,198,223)"
id="stop22" />
<stop
offset="70.97%"
stop-color="rgb(169,202,224)"
id="stop23" />
<stop
offset="74.19%"
stop-color="rgb(174,206,226)"
id="stop24" />
<stop
offset="77.42%"
stop-color="rgb(181,209,227)"
id="stop25" />
<stop
offset="80.65%"
stop-color="rgb(188,213,228)"
id="stop26" />
<stop
offset="83.87%"
stop-color="rgb(195,217,229)"
id="stop27" />
<stop
offset="87.1%"
stop-color="rgb(203,221,230)"
id="stop28" />
<stop
offset="90.32%"
stop-color="rgb(210,225,230)"
id="stop29" />
<stop
offset="93.55%"
stop-color="rgb(218,228,229)"
id="stop30" />
<stop
offset="96.77%"
stop-color="rgb(224,230,227)"
id="stop31" />
<stop
offset="100%"
stop-color="rgb(227,228,223)"
id="stop32" />
</linearGradient>
<clipPath
id="sqClip">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect32" />
</clipPath>
<clipPath
id="ciClip">
<circle
cx="510"
cy="170"
r="130"
id="circle32" />
</clipPath>
<clipPath
id="sqClip-9">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect32-8" />
</clipPath>
<clipPath
id="clipPath1">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect1" />
</clipPath>
<clipPath
id="clipPath2">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect2" />
</clipPath>
<clipPath
id="clipPath3">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect3" />
</clipPath>
<clipPath
id="sqClip-9-1">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect32-8-7" />
</clipPath>
<clipPath
id="clipPath1-1">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect1-8" />
</clipPath>
<clipPath
id="clipPath2-7">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect2-8" />
</clipPath>
<clipPath
id="clipPath3-7">
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
id="rect3-7" />
</clipPath>
</defs>
<!-- Rounded square (favicon / app icon) -->
<rect
x="40"
y="40"
width="260"
height="260"
rx="44"
fill="url(#bgSq)"
id="rect33"
inkscape:export-filename="../Coding/bibdle/static/favicon.png"
inkscape:export-xdpi="23.63077"
inkscape:export-ydpi="23.63077" />
<!-- Circle (Discord server icon) -->
<circle
cx="510"
cy="170"
r="130"
fill="url(#bgSq)"
id="circle37"
inkscape:export-filename="../Coding/bibdle/static/bibdle-logo-circle.png"
inkscape:export-xdpi="378.09232"
inkscape:export-ydpi="378.09232" />
<rect
x="152"
y="78"
width="36"
height="184"
rx="5"
fill="#ffffff"
clip-path="url(#sqClip-9)"
id="rect34-6"
transform="matrix(0.89748134,0,0,1,357.18847,2.5366858)" />
<rect
x="128"
y="88"
width="84"
height="26"
rx="5"
fill="#ffffff"
clip-path="url(#sqClip-9)"
id="rect35-5"
transform="matrix(1,0,0,0.80796134,339.90604,30.104693)" />
<rect
x="96"
y="140"
width="148"
height="30"
rx="5"
fill="#ffffff"
clip-path="url(#sqClip-9)"
id="rect36-7"
transform="matrix(1,0,0,0.82878095,339.90604,26.507353)" />
<rect
x="128"
y="210"
width="84"
height="20"
rx="4"
fill="#ffffff"
clip-path="url(#sqClip-9)"
transform="rotate(15,330.33996,1512.3487)"
id="rect37-6" />
<rect
x="152"
y="78"
width="36"
height="184"
rx="5"
fill="#ffffff"
clip-path="url(#sqClip-9-1)"
id="rect34-6-4"
transform="matrix(0.89748134,0,0,1,17.264653,0.15299483)" />
<rect
x="128"
y="88"
width="84"
height="26"
rx="5"
fill="#ffffff"
clip-path="url(#sqClip-9-1)"
id="rect35-5-4"
transform="matrix(1,0,0,0.80796134,0.28036275,27.721002)" />
<rect
x="96"
y="140"
width="148"
height="30"
rx="5"
fill="#ffffff"
clip-path="url(#sqClip-9-1)"
id="rect36-7-9"
transform="matrix(1,0,0,0.82878095,-0.01777671,24.123662)" />
<rect
x="128"
y="210"
width="84"
height="20"
rx="4"
fill="#ffffff"
clip-path="url(#sqClip-9-1)"
transform="rotate(15,169.4311,220.168)"
id="rect37-6-1" />
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

+1099
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE `daily_completions` ADD `guesses` text;--> statement-breakpoint
ALTER TABLE `user` ADD `google_id` text;--> statement-breakpoint
CREATE UNIQUE INDEX `user_google_id_unique` ON `user` (`google_id`);
+296
View File
@@ -0,0 +1,296 @@
{
"version": "6",
"dialect": "sqlite",
"id": "80883fb9-70cd-4fa5-b228-36358ffc4c40",
"prevId": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
"tables": {
"daily_completions": {
"name": "daily_completions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"anonymous_id": {
"name": "anonymous_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guess_count": {
"name": "guess_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"guesses": {
"name": "guesses",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"anonymous_id_date_idx": {
"name": "anonymous_id_date_idx",
"columns": [
"anonymous_id",
"date"
],
"isUnique": false
},
"date_idx": {
"name": "date_idx",
"columns": [
"date"
],
"isUnique": false
},
"date_guess_idx": {
"name": "date_guess_idx",
"columns": [
"date",
"guess_count"
],
"isUnique": false
},
"daily_completions_anonymous_id_date_unique": {
"name": "daily_completions_anonymous_id_date_unique",
"columns": [
"anonymous_id",
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"daily_verses": {
"name": "daily_verses",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"book_id": {
"name": "book_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verse_text": {
"name": "verse_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"daily_verses_date_unique": {
"name": "daily_verses_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"apple_id": {
"name": "apple_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_private": {
"name": "is_private",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_apple_id_unique": {
"name": "user_apple_id_unique",
"columns": [
"apple_id"
],
"isUnique": true
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"columns": [
"google_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -22,6 +22,13 @@
"when": 1770961427714, "when": 1770961427714,
"tag": "0002_outstanding_hiroim", "tag": "0002_outstanding_hiroim",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1774416309647,
"tag": "0003_overjoyed_mindworm",
"breakpoints": true
} }
] ]
} }
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
# Export all daily verses to JSON
# Usage: ./export-verses.sh [path/to/database.db] [output.json]
DB="${1:-./local.db}"
OUT="${2:-verses.json}"
sqlite3 "$DB" <<'SQL' > "$OUT"
.mode json
SELECT
CASE book_id
WHEN 'GEN' THEN 'Genesis'
WHEN 'EXO' THEN 'Exodus'
WHEN 'LEV' THEN 'Leviticus'
WHEN 'NUM' THEN 'Numbers'
WHEN 'DEU' THEN 'Deuteronomy'
WHEN 'JOS' THEN 'Joshua'
WHEN 'JDG' THEN 'Judges'
WHEN 'RUT' THEN 'Ruth'
WHEN '1SA' THEN '1 Samuel'
WHEN '2SA' THEN '2 Samuel'
WHEN '1KI' THEN '1 Kings'
WHEN '2KI' THEN '2 Kings'
WHEN '1CH' THEN '1 Chronicles'
WHEN '2CH' THEN '2 Chronicles'
WHEN 'EZR' THEN 'Ezra'
WHEN 'NEH' THEN 'Nehemiah'
WHEN 'EST' THEN 'Esther'
WHEN 'JOB' THEN 'Job'
WHEN 'PSA' THEN 'Psalms'
WHEN 'PRO' THEN 'Proverbs'
WHEN 'ECC' THEN 'Ecclesiastes'
WHEN 'SNG' THEN 'Song of Solomon'
WHEN 'ISA' THEN 'Isaiah'
WHEN 'JER' THEN 'Jeremiah'
WHEN 'LAM' THEN 'Lamentations'
WHEN 'EZK' THEN 'Ezekiel'
WHEN 'DAN' THEN 'Daniel'
WHEN 'HOS' THEN 'Hosea'
WHEN 'JOL' THEN 'Joel'
WHEN 'AMO' THEN 'Amos'
WHEN 'OBA' THEN 'Obadiah'
WHEN 'JON' THEN 'Jonah'
WHEN 'MIC' THEN 'Micah'
WHEN 'NAM' THEN 'Nahum'
WHEN 'HAB' THEN 'Habakkuk'
WHEN 'ZEP' THEN 'Zephaniah'
WHEN 'HAG' THEN 'Haggai'
WHEN 'ZEC' THEN 'Zechariah'
WHEN 'MAL' THEN 'Malachi'
WHEN 'MAT' THEN 'Matthew'
WHEN 'MRK' THEN 'Mark'
WHEN 'LUK' THEN 'Luke'
WHEN 'JHN' THEN 'John'
WHEN 'ACT' THEN 'Acts'
WHEN 'ROM' THEN 'Romans'
WHEN '1CO' THEN '1 Corinthians'
WHEN '2CO' THEN '2 Corinthians'
WHEN 'GAL' THEN 'Galatians'
WHEN 'EPH' THEN 'Ephesians'
WHEN 'PHP' THEN 'Philippians'
WHEN 'COL' THEN 'Colossians'
WHEN '1TH' THEN '1 Thessalonians'
WHEN '2TH' THEN '2 Thessalonians'
WHEN '1TI' THEN '1 Timothy'
WHEN '2TI' THEN '2 Timothy'
WHEN 'TIT' THEN 'Titus'
WHEN 'PHM' THEN 'Philemon'
WHEN 'HEB' THEN 'Hebrews'
WHEN 'JAS' THEN 'James'
WHEN '1PE' THEN '1 Peter'
WHEN '2PE' THEN '2 Peter'
WHEN '1JN' THEN '1 John'
WHEN '2JN' THEN '2 John'
WHEN '3JN' THEN '3 John'
WHEN 'JUD' THEN 'Jude'
WHEN 'REV' THEN 'Revelation'
ELSE book_id
END AS book,
verse_text AS verse,
reference AS citation,
date
FROM daily_verses
ORDER BY date;
SQL
echo "Exported to $OUT"
+1
View File
@@ -35,6 +35,7 @@
}, },
"dependencies": { "dependencies": {
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"drizzle": "^1.4.0",
"fast-xml-parser": "^5.3.3", "fast-xml-parser": "^5.3.3",
"marked": "^17.0.4", "marked": "^17.0.4",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
+67
View File
@@ -0,0 +1,67 @@
# Community Verse Submissions — Implementation Plan
Source of truth: `spec.md`. Each step is independently verifiable and builds on the previous.
## Step 1 — Schema & migration
- Add `verse_submissions` table to `src/lib/server/db/schema.ts`:
- `id` text pk (`Bun.randomUUIDv7()`)
- `user_id` text not null, FK → `user.id` **ON DELETE SET NULL** (nullable column to allow that)
- `scheduled_date` text not null **unique**
- `selected_book_id` text not null
- `selected_chapter` integer not null
- `selected_verse` integer not null
- `submitted_at` integer not null (server UTC **millis**; raw integer, drives 7-day cooldown)
- indexes: `user_id`, unique on `scheduled_date` (the column-level unique handles this)
- `bun run db:push` to dev.db.
- Verify: `sqlite3 dev.db ".schema verse_submissions"`.
> Note: spec says `user_id` FK should be SET NULL on account deletion, so the column is **nullable** despite "not null" in the table sketch (the edge-cases section overrides: "FK should be ON DELETE SET NULL"). Resolution: make `user_id` nullable + ON DELETE SET NULL. The admin LEFT JOIN renders "(deleted user)" when null. This is the spec-intended behavior.
## Step 2 — Bible structure + windowing helpers
- Export `getChapterCount`, `getVerseCount` from `xml-bible.ts` (currently private).
- Add `composeVerseWindow(bookNumber, chapter, verse)`:
- Computes the selected verse's index `v` within the book (contiguous 1-based across chapters), `bookStart`=1, `bookEnd`=total verses in book.
- Default window `[v-2, v-1, v]`; if `v-2 < 1` fall forward to `[v, v+1, v+2]`.
- Returns `{ bookId, bookName, verses: string[], startChapter, startVerse, endChapter, endVerse, selectedVerse }` by calling `extractVerses` per chapter and concatenating for cross-chapter windows.
- Add `formatWindowReference(bookName, startChapter, startVerse, endChapter, endVerse)`: same-chapter → `Book C:S-E`; cross-chapter → `Book C1:S1C2:E2`.
- Unit tests in `tests/verse-window.test.ts`.
## Step 3 — Public APIs
- `GET /api/bible/structure``[{ bookId, chapters: number[] }, ...]` for all 66 books (verse counts per chapter). Cached in-memory.
- `GET /api/verse-window?bookId=gen&chapter=1&verse=1``{ windowVerses, reference, bookId, selectedVerse }` via `composeVerseWindow`.
- Verify with curl.
## Step 4 — Admin module + `/scheduled-verses` page
- `src/lib/server/admin.ts` exporting `ADMIN_EMAIL = 'geohpowell@gmail.com'`.
- `src/routes/scheduled-verses/+page.server.ts`: require `locals.user?.email === ADMIN_EMAIL` else auth redirect / 403 access-denied page.
- `+page.svelte`: table joining `verse_submissions` LEFT JOIN `daily_verses` (on `scheduled_date = date`) LEFT JOIN `user` (on `user_id`), sorted by `scheduled_date` asc. Columns per spec. "(deleted user)" when user null.
- Not linked from nav. Verify by direct URL.
## Step 5 — Submit backend + lazy gap-fill
- `POST /api/submit-verse` (auth required): body `{ bookId, chapter, verse, localDate }`.
1. Require `locals.user` (401).
2. Solved-today gate: `dailyCompletions` row `(anonymousId=user.id, date=localDate)` else 403.
3. Cooldown: most recent `verse_submissions.submitted_at` for user; if `< 7d` ago → 429 `{ cooldownEndsAt }`.
4. Structural validation: bookId valid, chapter in `[1, getChapterCount]`, verse in `[1, getVerseCount]`.
5. Compute window via `composeVerseWindow`.
6. Scheduling scan: fetch all `daily_verses` with `date > today` (server UTC) ordered; walk day-by-day from tomorrow; validity = empty + no back-to-back with D-1/D+2 committed neighbors + no same-window-ref row within ±60 days (bounded lookup). Find earliest valid `D`.
7. Transaction: insert `daily_verses` `{ id, date: D, bookId, reference, verseText, createdAt: now }` + `verse_submissions` `{ id, user_id, scheduled_date: D, selected_book_id, selected_chapter, selected_verse, submitted_at: now }`. On `SQLITE_CONSTRAINT_UNIQUE` retry scan from `D+1`, up to 5 attempts.
8. Return `{ scheduledDate: D, reference, windowText }`. Emit rybbit event.
- `GET /api/submit-verse/status?localDate=YYYY-MM-DD` (auth required): `{ canSubmit, cooldownEndsAt, lastSubmission, upcoming }`.
- Modify `getVerseForDate` lazy path: re-roll `getRandomVerses()` until book differs from committed D-1 and D+1 neighbors (cap ~20 retries).
- Verify with curl end-to-end.
## Step 6 — Frontend `SubmitVerse.svelte`
- New component rendered in `WinScreen.svelte` near the progress button, sharing `.progress-btn` styling.
- States: logged-out (Apple/Google dropdown mirroring progress button), cooldown (greyed + `CountdownTimer`-style countdown to `cooldownEndsAt`), can-submit (cascading Book→Chapter→Verse `<select>`s from `/api/bible/structure`, debounced 250ms preview via `/api/verse-window`, Submit button, success confirmation with scheduled date, inline 429/403 errors).
- Pass `isLoggedIn` + `anonymousId` through (already on WinScreen props).
- Verify in browser.
## Step 7 — Tests
- `tests/verse-window.test.ts`: book-start fall-forward (Gen 1:1 → [1,2,3]), cross-chapter window, same-chapter window, end-of-book safety.
- `tests/scheduling.test.ts` (or unit-test the pure scan function against an in-memory fixture): no-back-to-back, 60-day repeat skip, earliest-valid-date selection.
- Cooldown arithmetic test.
## Step 8 — Docs
- Update `README.md` schema table (add `verse_submissions`) and routes/API tables (new endpoints + `/scheduled-verses`).
- Note `ADMIN_EMAIL` constant location.
+299
View File
@@ -0,0 +1,299 @@
# Spec: Community Verse Submissions
## TODO
- [x] **Step 1 — Schema & migration:** add `verse_submissions` table to `src/lib/server/db/schema.ts` (nullable `user_id` FK → `user.id` ON DELETE SET NULL, unique `scheduled_date`, index on `user_id`), pushed to dev.db.
- [x] **Step 2 — Bible structure + windowing helpers:** export `getChapterCount`/`getVerseCount` from `xml-bible.ts`; add `composeVerseWindow()` (fall-forward 3-verse window, never crosses book, crosses chapter within book) and `formatWindowReference()` (hyphen same-chapter, en-dash cross-chapter). Tests in `tests/verse-window.test.ts` (17 pass).
- [x] **Step 3 — Public APIs:** `GET /api/bible/structure` (66-book verse counts for cascading dropdowns) and `GET /api/verse-window` (live 3-verse preview).
- [x] **Step 4 — Admin module + `/scheduled-verses` page:** `src/lib/server/admin.ts` (`ADMIN_EMAIL`), auth-walled admin view joining `verse_submissions``daily_verses``user`.
- [x] **Step 5 — Submit backend + lazy gap-fill:** `POST /api/submit-verse` (solved-today gate, 7-day cooldown, structural validation, scheduling scan with empty/no-back-to-back/60-day-repeat rules, transaction + retry), `GET /api/submit-verse/status`, modified `getVerseForDate` neighbor avoidance.
- [x] **Step 6 — Frontend `SubmitVerse.svelte`:** three states (logged-out sign-in dropdown, cooldown + countdown, cascading selects + preview + submit), wired into `WinScreen.svelte`.
- [x] **Step 7 — Tests:** scheduling validity, cooldown arithmetic, concurrency.
- [x] **Step 8 — Docs:** update README schema + routes/API tables.
---
Let authenticated users submit a Bible verse they love. Submissions are scheduled as future "verses of the day," mixed so that **no two consecutive calendar days ever feature the same book**, and spaced so an exact 3-verse window never repeats within 60 days. Selection is dropdown-based (cascading `<select>`s) so the system can compose verses purely from existing Bible-lookup functions already in the codebase — no free-text entry, so typos are impossible and content is always canonical NKJV text.
## Goals
- A new button on the **win screen** (`WinScreen.svelte`), visually consistent with the existing "📈 See your progress" neobrutalist button.
- Logged-out users who click it get a dropdown prompting sign-in (mirroring the existing progress button's Apple/Google dropdown).
- Logged-in users get a cascading Book → Chapter → Verse selector with a live 3-verse preview.
- Each submission immediately reserves a concrete future date; the submitter is shown that date on success.
- A 7-day rolling cooldown (server UTC) gates submissions, with the button greyed out and a countdown timer shown while active.
- Attribution is **anonymous everywhere** — submissions are never credited to a user publicly.
## Non-goals (explicitly decided)
- **No giveaway-text filtering.** Verses that mention their own book/author (e.g. Eph 1:1, Isa 1:1) are allowed. The random generator already permits these.
- **No persistent pending queue / no cron.** Assignment happens at submit time, not via a background job.
- **No withdrawal / deletion / editing.** Submissions are final (references come from dropdowns, so typos are impossible).
- **No guardrail on how far out a verse is scheduled.** If the calendar is dense, a submission may land months or years in the future; this is accepted.
- **No per-user pending cap, no global cap.** The 7-day rolling cooldown is the only rate limit.
- **No attribution / leaderboard / share-text changes.** Submitter identity is recorded only for rate-limiting and the solved-today gate; it is never displayed.
- **No backfill of gap days.** Sparse future `dailyVerses` rows are fine.
---
## Data Model
### New table: `verse_submissions`
A log of who submitted what and when, plus the assigned scheduled date. The canonical verse text/reference lives in `daily_verses` (keyed by `date`); this table links a user to a scheduled date.
| Column | Type | Notes |
| --- | --- | --- |
| `id` | text (pk) | `Bun.randomUUIDv7()` |
| `user_id` | text, not null, FK → `user.id` | the submitter (never displayed) |
| `scheduled_date` | text, not null, **unique** | `YYYY-MM-DD` of the reserved `daily_verses` row |
| `selected_book_id` | text, not null | the book the user chose |
| `selected_chapter` | integer, not null | the chapter the user chose |
| `selected_verse` | integer, not null | the single verse the user chose (the "anchor") |
| `submitted_at` | integer (timestamp), not null | server UTC millis — drives the 7-day cooldown |
Indexes: `user_id` (cooldown lookup + "your upcoming verses"), `scheduled_date` (unique — one submission per scheduled date).
No `status`, `withdrawn_at`, or attribution columns. Denormalized window/verseText/reference are **not** stored here; they live on `daily_verses` and are joined by `scheduled_date`.
### `daily_verses` (unchanged schema)
Submissions simply write a new row with a future `date`, exactly as the existing lazy path does for the current day. `getVerseForDate(date)` already returns a pre-existing row if present, so a pre-written submission row for a future date is served unchanged when its day arrives.
The new behavior:
- **Submission rows** are written at submit time for a future date (the date returned by the scheduling scan).
- **Gap days** (dates with no committed row when a player arrives) are filled lazily by the existing random path — extended to respect the no-back-to-back rule (see *Lazy gap-fill* below).
---
## Verse Windowing
A submission selects **one** verse (book → chapter → verse). The displayed daily verse is a **3-verse window** anchored on that verse, for difficulty consistency with the existing game (`getRandomVerses` defaults to 3 consecutive verses).
### Window algorithm (deterministic)
Let `v` be the selected verse's index within its **book** (a contiguous 1-based index across all chapters of that book). Let `bookStart` = 1 (first verse index in the book) and `bookEnd` = last verse index in the book.
- Default window: `[v-2, v-1, v]` (the two preceding in-book verses + the selected verse).
- If `v - 2 < bookStart` (not enough preceding verses in-book — only happens at a book's very start, e.g. Gen 1:1, John 1:1): **fall forward** so the window is 3 consecutive in-book verses that still includes `v`, i.e. start = `v`, window = `[v, v+1, v+2]`. (Every Bible book has ≥3 verses after its opening, so this always succeeds.)
- The window **never crosses a book boundary**. Crossing a chapter boundary **within the same book** is allowed and expected (e.g. a window spanning the end of chapter 1 and the start of chapter 2).
This reuses existing functions: `getChapterCount`, `getVerseCount`, `extractVerses` (called per chapter; for cross-chapter windows, call `extractVerses` twice and concatenate), `getBookByNumber`/`getBookById`, and `formatReference` (extended to format cross-chapter ranges, e.g. `Genesis 1:312:1`; same-chapter stays `Genesis 1:1-3`).
The selected verse's **book** is the day's `bookId` (the guess target). All three displayed verses come from that same book, so the guessing game stays fair.
---
## Scheduling Algorithm (assign-at-submit)
When a submission is accepted, the server scans forward from **tomorrow** (server UTC date) to find the earliest valid candidate date `D`, then writes the `daily_verses` row for `D` immediately and records a `verse_submissions` row. The submitter is shown `D`.
### Validity checks for candidate date `D` and submission book `B`
`D` is valid iff **all** hold:
1. **Empty:** no `daily_verses` row exists at `D`.
2. **No back-to-back with prior committed neighbor:** the `daily_verses` row at `D-1` (if one exists) must have `book_id != B`.
3. **No back-to-back with next committed neighbor:** the `daily_verses` row at `D+1` (if one exists) must have `book_id != B`.
4. **60-day repeat distance:** no `daily_verses` row with the **same 3-verse window** exists whose date is within `[D-60, D+60]` days inclusive. The window identity is its canonical reference string (`book_id` + formatted `chapter:startverse-endverse`, cross-chapter-aware). This is a *soft* repeat rule: repeats are allowed, just not within ±60 days of any prior/scheduled appearance.
### Implementation note (performance)
Don't query per candidate date. Instead, fetch all `daily_verses` rows with `date > today` ordered by date into memory, then walk day-by-day from tomorrow, checking the four rules against the in-memory set + a single range query for the 60-day repeat check (windowed by reference). Historical rows (date ≤ today) only matter for rule 4; a bounded lookup (rows with the same window-ref within 60 days of `D`) suffices.
### Concurrency
The scan-then-insert is a critical section. Two concurrent submissions could both select the same `D`. Wrap insert in a transaction; on a `SQLITE_CONSTRAINT_UNIQUE` failure on `daily_verses.date` (or `verse_submissions.scheduled_date`), re-run the scan from `D+1`. Retry up to a small bound (e.g. 5). SQLite under `bun:sqlite` handles this with a transaction.
### Why this preserves no-back-to-back globally
Adjacency is only ever checked against **committed** rows (D-1, D+1). Gap days (empty between two committed rows) are filled lazily later by a random verse whose generator also respects committed neighbors (see below). So at play-time, every adjacent pair of days has been checked at write-time. The invariant is maintained inductively.
---
## Lazy Gap-Fill (modified `getVerseForDate`)
When a player opens a date with no committed `daily_verses` row, the existing random path runs — **extended** so the random verse's book differs from any committed neighbor:
- If `D-1` has a committed row with book `X`, the random book must be `!= X`.
- If `D+1` has a committed row with book `Y`, the random book must be `!= Y`.
- Re-try `getRandomVerses()` until both hold (66 books, ≤2 excluded → ~97% success per try; cap at ~20 retries, then accept). `getRandomVerses` already returns 3 consecutive in-book verses; no windowing change needed for random fillers.
Submission days are pre-written, so they skip this path entirely (`getVerseForDate` returns the existing row).
---
## Rate Limiting & Cooldown
- **One submission per user per rolling 7×24h window**, measured in **server UTC** (not gameable; smooth countdown).
- `cooldownEndsAt = lastSubmission.submitted_at + 7 days` (server UTC millis).
- The win-screen button is **always visible**; when the cooldown is active it is **greyed out and disabled**, with a small countdown timer below it (target `cooldownEndsAt`).
- Query: select the user's most recent `verse_submissions.submitted_at`; if `now - submitted_at < 7d`, cooldown is active.
### Solved-today gate (API-level)
The `POST /api/submit-verse` endpoint requires that the user has a `daily_completions` row for **their local today**. The client sends `localDate` (the same `YYYY-MM-DD` it already computes via `toLocaleDateString("en-CA")` and sends to other endpoints). The server checks `dailyCompletions` for a row with `anonymous_id = user.id` (for logged-in users, `anonymousId` is the user id after migration) and `date = localDate`. If absent → `403` with `{ error: "Solve today's puzzle first" }`.
This is an **engagement gate**, not a security necessity (submissions are anonymous → low spam incentive), but it ties the feature to real play and blocks scripted submissions.
---
## Validation (no content moderation)
Because verses are chosen from canonical NKJV via dropdowns, no profanity/inappropriate-content filter is needed. Pre-submit validation is purely structural:
- `bookId` is one of the 66 known books (`getBookById`).
- `chapter` is in `[1, getChapterCount(bookNumber)]`.
- `verse` is in `[1, getVerseCount(bookNumber, chapter)]`.
- Cooldown not active.
- Solved-today gate passed.
- (The 60-day repeat and no-back-to-back rules are enforced by the scheduling scan, not by pre-rejecting the user's selection. A user can pick any valid verse; the scan will simply place it on the earliest valid date, possibly far out.)
---
## API Endpoints
### `/scheduled-verses` (auth-walled, admin-only)
A private admin page gated to the single account `geohpowell@gmail.com`. This is the **one place** where submitter identity is surfaced — attribution is anonymous everywhere else (public game, share text, etc.), but this admin view is the exception.
**Access control:**
- `+page.server.ts` `load` requires `locals.user` and `locals.user.email === 'geohpowell@gmail.com'`.
- Not authenticated → redirect to the sign-in modal/flow (same pattern as `/progress`, `/stats`).
- Authenticated but wrong account → `403` / a plain "not authorized" page. Do not leak the existence of privileged data; the page simply renders an access-denied state.
- The email check is a literal string match; if the admin email ever changes, update the constant (single source in one server module, e.g. `src/lib/server/admin.ts` exporting `ADMIN_EMAIL`).
- No `isPrivate` bypass needed — the admin is trusted to see all submitters regardless of their `isPrivate` flag.
**Page contents:** a table of all `verse_submissions` rows joined to `daily_verses` (by `scheduled_date = daily_verses.date`) and `user` (by `user_id`), one row per submission:
| Column | Source |
| --- | --- |
| Scheduled date | `verse_submissions.scheduled_date` (rendered human-readable) |
| Reference | `daily_verses.reference` |
| Window text | `daily_verses.verse_text` (the 3-verse window) |
| Book | `daily_verses.book_id` (resolve to name via `getBookById`) |
| Submitted by | `user.email` (and `firstName lastName` if present) |
| Submitted at | `verse_submissions.submitted_at` (server UTC, rendered) |
| Selected verse | `verse_submissions.selected_book_id` / `selected_chapter` / `selected_verse` (the anchor the user picked) |
Sorted by `scheduled_date` ascending (so the upcoming calendar reads top-to-bottom). Include past rows (already-played submissions) — the table is a full historical + future log, not just pending. A small filter toggle (All / Upcoming / Past) is a nice-to-have, not required.
Since attribution appears here, `verse_submissions.user_id` must resolve even for deleted accounts — use a `LEFT JOIN` to `user` and render "(deleted user)" when the join is null. (Matches the `ON DELETE SET NULL` decision in the edge-cases section: the scheduled verse still plays out; the admin just sees the attribution is gone.)
This route is **not** linked from the UI (no nav entry, no win-screen button) — it's a direct-URL admin tool.
### `POST /api/submit-verse` (auth required)
**Body:** `{ bookId, chapter, verse, localDate }`
**Flow:**
1. Require `locals.user` (else `401`).
2. Solved-today gate: `dailyCompletions` row for `(anonymousId=user.id, date=localDate)` else `403`.
3. Cooldown: most recent `verse_submissions.submitted_at` for user; if `< 7d` ago → `429 { cooldownEndsAt }`.
4. Structural validation of `bookId/chapter/verse`.
5. Compute the 3-verse window (fall-forward algorithm).
6. Run the scheduling scan; on unique-constraint conflict, retry.
7. In a transaction: insert `daily_verses` row `{ id, date: D, bookId, reference, verseText, createdAt: now }` and `verse_submissions` row `{ id, user_id, scheduled_date: D, selected_book_id, selected_chapter, selected_verse, submitted_at: now }`.
8. Return `{ scheduledDate: D, reference, windowText }`.
Analytics: emit `(window as any).rybbit?.event("Submit a verse")`.
### `GET /api/submit-verse/status?localDate=YYYY-MM-DD` (auth required)
Returns the state for the win-screen button:
```json
{
"canSubmit": true | false,
"cooldownEndsAt": <server UTC millis> | null,
"lastSubmission": { "scheduledDate": "2026-08-24", "reference": "..." } | null,
"upcoming": [ { "scheduledDate": "...", "reference": "..." }, ... ] // this user's not-yet-reached submissions, for an optional small list
}
```
### `GET /api/bible/structure` (public)
Returns the static structure for populating the cascading dropdowns client-side (cached indefinitely):
```json
[
{ "bookId": "gen", "chapters": [31, 25, 24, ...] }, // verse counts per chapter
...
]
```
Built from `getChapterCount` + `getVerseCount` over all 66 books. Small payload (~1189 chapter entries).
### `GET /api/verse-window?bookId=gen&chapter=1&verse=1` (public)
Returns the 3-verse preview for the live preview pane, computed via the fall-forward window algorithm:
```json
{
"windowVerses": ["In the beginning...", "And the earth...", "And God said..."],
"reference": "Genesis 1:1-3",
"bookId": "gen",
"selectedVerse": 1
}
```
Called debounced (e.g. 250ms) as the selector changes.
---
## Frontend
### New component: `src/lib/components/SubmitVerse.svelte`
Rendered inside `WinScreen.svelte`, near the existing "📈 See your progress" button, sharing the same neobrutalist `.progress-btn` styling.
**Three visual states:**
1. **Logged out** — button reads "✨ Submit a verse" with a chevron; clicking expands a dropdown identical in structure to the existing logged-out progress dropdown: helper text ("Sign in to submit a verse for a future day") + Apple Sign-In form (`POST /auth/apple`, with hidden `anonymousId`) + Google Sign-In form (`POST /auth/google`). Mirrors the existing markup/classes exactly.
2. **Logged in, cooldown active** — button is greyed out / disabled ("✨ Submit a verse" + lock/⏳), with a small countdown timer below it counting down to `cooldownEndsAt` (reuse the `CountdownTimer` pattern / a compact timer). The dropdown does not expand.
3. **Logged in, can submit** — clicking expands a panel with:
- Three cascading native `<select>` dropdowns: **Book** (grouped by Testament, `<optgroup>`), **Chapter** (1..chapterCount), **Verse** (1..verseCount). Book list/chapter counts come from `/api/bible/structure` (fetched once, cached). Selecting book populates chapters; selecting chapter populates verses; selecting verse (or any change) triggers the debounced preview fetch.
- A **live preview** card showing the 3-verse window text + reference, updating in real time as the selector changes. (This is not today's verse — it's the user's prospective submission — so showing the book/reference is fine.)
- A **Submit** button (neobrutalist). On success, replace the panel with a confirmation: "✅ Your verse is scheduled for **August 24, 2026**" + the reference, plus a rybbit event. On `429`/`403`, show the server's error message inline.
The win screen already receives `isLoggedIn` and `anonymousId` as props; pass them through to `SubmitVerse`.
### Placement note
The button is **win-screen-only**, which implicitly requires the user to have solved today's puzzle to reach it (the win screen only renders post-solve, and reloads still render it via `game-persistence` restoring today's completed guesses). This matches the API-level solved-today gate.
---
## Reused Existing Functions
| Function | Used for |
| --- | --- |
| `getChapterCount(bookNumber)` | Dropdown chapter list bounds |
| `getVerseCount(bookNumber, chapter)` | Dropdown verse list bounds |
| `extractVerses(bookNumber, chapter, startVerse, count)` | Composing the 3-verse window (per chapter; concat for cross-chapter) |
| `getBookById` / `getBookByNumber` | Book id ↔ number ↔ name resolution |
| `formatReference(bookName, chapter, startVerse, endVerse)` | Reference formatting (extend for cross-chapter) |
| `getRandomVerses()` | Lazy gap-fill random verse (extended with neighbor avoidance) |
| `getVerseForDate(date)` | Returns pre-written submission row unchanged on its day |
| Auth (`locals.user`, session cookie) | Identifying the submitter |
| `dailyCompletions` (`anonymousId = user.id`, `date`) | Solved-today gate |
---
## Edge Cases & Decisions Log
- **Selected verse at a book's start (v1/v2 of chapter 1):** fall forward to `[v, v+1, v+2]`. Always 3 verses, same book.
- **Cross-chapter window within a book:** allowed; reference formatted as `Book C1:S1C2:E2`.
- **Cross-book window:** never happens (algorithm constrains to one book).
- **Concurrent submissions picking the same date:** transaction + retry-on-unique-conflict.
- **Repeat of an identical 3-verse window:** allowed, but the scan skips any candidate date within ±60 days of an existing same-window row, so repeats land far out (this is what "schedule them out a month or two" means in practice).
- **Calendar drift (submissions outpace 1/day demand):** accepted — no guardrail; a submission may land far in the future. The submitter is shown the resulting date regardless.
- **Account deletion:** submissions are anonymous; `daily_verses` rows stay (canonical text). The `verse_submissions` row's `user_id` FK should be `ON DELETE SET NULL` (or cascade-delete the log row) — either is fine since attribution is never shown. The scheduled verse plays out regardless.
- **Discord daily-verse cron / RSS / sitemap:** unchanged — they call `getVerseForDate`, which serves pre-written submission rows transparently.
- **`createdAt` on submission-written `daily_verses` rows:** equals submit time, not the future play date. This is consistent with the existing field's meaning ("when the row was created").
- **Admin `/scheduled-verses` view is the sole attribution exception:** the public-facing anonymity guarantee does not apply to this admin-only route, which deliberately surfaces `user.email` for the `geohpowell@gmail.com` account. All other surfaces (win screen, share text, future potential leaderboard) remain anonymous.
---
## Open / Deferred (not blocking v1)
- A small "your upcoming scheduled verses" list on the win screen (data already available from `/api/submit-verse/status`). Nice-to-have, not required for v1.
- Balancing book distribution beyond no-back-to-back (e.g. avoiding clustering the same book within a month). Explicitly out of scope — only adjacent-day collisions are prevented.
- Email/notification when a user's scheduled verse goes live. Out of scope.
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <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> <link rel="icon" href="/favicon.png" type="image/png" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+2 -2
View File
@@ -17,9 +17,9 @@ const handleAuth: Handle = async ({ event, resolve }) => {
const { session, user } = await auth.validateSessionToken(sessionToken); const { session, user } = await auth.validateSessionToken(sessionToken);
if (session) { if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); auth.setSessionTokenCookie({ cookies: event.cookies }, sessionToken, session.expiresAt);
} else { } else {
auth.deleteSessionTokenCookie(event); auth.deleteSessionTokenCookie({ cookies: event.cookies });
} }
event.locals.user = user; event.locals.user = user;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

+200
View File
@@ -0,0 +1,200 @@
<script lang="ts">
import { onMount } from "svelte";
import Container from "$lib/components/Container.svelte";
interface Props {
completions: Array<{ date: string; guessCount: number }>;
}
let { completions }: Props = $props();
type CalendarCell = {
date: string;
dayNum: number;
played: boolean;
guessCount: number | null;
} | null;
type CalendarRow = {
cells: CalendarCell[];
monthLabel: string | null;
};
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function buildCalendar(
completionList: Array<{ date: string; guessCount: number }>,
localDate: string,
): CalendarRow[] {
const completionMap = new Map(
completionList.map((c) => [c.date, c.guessCount]),
);
const today = new Date(localDate + "T00:00:00Z");
const days: Array<{
date: string;
dayNum: number;
month: string;
dayOfWeek: number;
guessCount: number | null;
}> = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - i);
const dateStr = d.toISOString().slice(0, 10);
days.push({
date: dateStr,
dayNum: d.getUTCDate(),
month: dateStr.slice(0, 7),
dayOfWeek: d.getUTCDay(),
guessCount: completionMap.get(dateStr) ?? null,
});
}
const rows: CalendarRow[] = [];
let currentRow: CalendarCell[] = [];
let currentMonth = "";
let firstRowOfMonth = true;
for (const day of days) {
if (day.month !== currentMonth) {
if (currentRow.length > 0) {
while (currentRow.length < 7) currentRow.push(null);
rows.push({ cells: currentRow, monthLabel: null });
currentRow = [];
}
for (let j = 0; j < day.dayOfWeek; j++) currentRow.push(null);
currentMonth = day.month;
firstRowOfMonth = true;
}
currentRow.push({
date: day.date,
dayNum: day.dayNum,
played: day.guessCount !== null,
guessCount: day.guessCount ?? null,
});
if (currentRow.length === 7) {
const [year, monthIdx] = currentMonth.split("-").map(Number);
const label = firstRowOfMonth
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
: null;
rows.push({ cells: currentRow, monthLabel: label });
currentRow = [];
firstRowOfMonth = false;
}
}
if (currentRow.length > 0) {
while (currentRow.length < 7) currentRow.push(null);
const [year, monthIdx] = currentMonth.split("-").map(Number);
rows.push({
cells: currentRow,
monthLabel: firstRowOfMonth
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
: null,
});
}
return rows;
}
function calendarColor(day: {
played: boolean;
guessCount: number | null;
}): string {
if (!day.played) return "bg-gray-800/60 text-gray-400";
const g = day.guessCount!;
if (g === 1) return "bg-emerald-300";
if (g <= 3) return "bg-emerald-500";
if (g <= 5) return "bg-amber-400";
if (g <= 7) return "bg-orange-500";
return "bg-red-600";
}
let calendarRows = $state<CalendarRow[]>([]);
onMount(() => {
const localDate = new Date().toLocaleDateString("en-CA");
calendarRows = buildCalendar(completions, localDate);
});
</script>
<Container class="p-4 md:p-6 w-full">
<h2 class="text-xl font-bold text-gray-100 mb-3 w-full text-left">
Activity
</h2>
<!-- Day-of-week headers -->
<div class="flex gap-1 mb-1">
{#each ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as d (d)}
<div
class="w-10 h-5 text-center text-[10px] text-gray-500 font-medium shrink-0"
>
{d}
</div>
{/each}
</div>
<!-- Calendar rows -->
{#each calendarRows as row, rowIdx (rowIdx)}
{#if row.monthLabel}
<div class="text-xs text-gray-400 font-semibold mt-3 mb-1">
{row.monthLabel}
</div>
{/if}
<div class="flex gap-1 mb-1">
{#each row.cells as cell, cellIdx (cellIdx)}
{#if cell}
<div
class="w-10 h-10 rounded flex items-center justify-center text-sm font-semibold shrink-0 {calendarColor(
cell,
)}"
title={cell.played
? `${cell.date}: ${cell.guessCount} guess${cell.guessCount === 1 ? "" : "es"}`
: cell.date}
>
{cell.dayNum}
</div>
{:else}
<div class="w-10 h-10 shrink-0"></div>
{/if}
{/each}
</div>
{/each}
<!-- Legend -->
<div class="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-300"></span>
1 guess
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-500"></span>
23 guesses
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded-sm bg-amber-400"></span>
45 guesses
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded-sm bg-orange-500"></span>
67 guesses
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded-sm bg-red-600"></span>
8+ guesses
</span>
</div>
</Container>
+18
View File
@@ -96,6 +96,7 @@
<button <button
type="submit" type="submit"
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium" class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
data-umami-event="Sign in with Apple"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/> <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"/>
@@ -104,6 +105,23 @@
</button> </button>
</form> </form>
<form method="POST" action="/auth/google">
<input type="hidden" name="anonymousId" value={anonymousId} />
<button
type="submit"
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium mt-3"
data-umami-event="Sign in with Google"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign in with Google
</button>
</form>
<div class="flex items-center my-4"> <div class="flex items-center my-4">
<div class="flex-1 border-t border-white/20"></div> <div class="flex-1 border-t border-white/20"></div>
<span class="px-3 text-sm text-white/60">or</span> <span class="px-3 text-sm text-white/60">or</span>
+3
View File
@@ -7,6 +7,7 @@
onclick?: () => void; onclick?: () => void;
class?: string; class?: string;
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
disabled?: boolean;
} }
let { let {
@@ -15,6 +16,7 @@
onclick, onclick,
class: className = "", class: className = "",
type = "button", type = "button",
disabled = false,
}: Props = $props(); }: Props = $props();
const variantClasses = { const variantClasses = {
@@ -31,6 +33,7 @@
<button <button
{type} {type}
{onclick} {onclick}
{disabled}
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[ class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
variant variant
]} {className}" ]} {className}"
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
interface Header {
label: string;
align?: 'left' | 'right';
width?: string;
}
interface Mode {
value: string;
label: string;
}
interface Props {
rows: T[];
headers: Header[];
row: Snippet<[item: T]>;
empty?: Snippet;
initialRows?: number;
modes?: Mode[];
mode?: string;
}
let {
rows,
headers,
row: rowSnippet,
empty,
initialRows = 3,
modes,
mode = $bindable(modes && modes.length > 0 ? modes[0].value : undefined),
}: Props = $props();
let expanded = $state(false);
// Reset expanded when mode changes (e.g. switching Rolling 30d ↔ Calendar)
$effect(() => {
mode;
expanded = false;
});
function toggleExpanded() {
expanded = !expanded;
}
const displayedRows = $derived(expanded ? rows : rows.slice(0, initialRows));
</script>
{#if modes && modes.length > 1}
<div class="flex gap-1 bg-white/5 rounded-lg p-1 w-fit ml-auto mb-3">
{#each modes as m (m.value)}
{@const active = mode === m.value}
<button
onclick={() => (mode = m.value)}
class="px-3 py-1 text-xs rounded-md transition-colors {active ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
>
{m.label}
</button>
{/each}
</div>
{/if}
{#if rows.length === 0}
{#if empty}
{@render empty()}
{:else}
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
{/if}
{:else}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
{#each headers as header (header.label)}
<th
class="{header.align === 'right' ? 'text-right' : 'text-left'} px-4 py-3{header.width ? ' ' + header.width : ''}"
>
{header.label}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each displayedRows as item, i (i)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
{@render rowSnippet(item)}
</tr>
{/each}
</tbody>
</table>
</div>
{#if rows.length > initialRows}
<button
onclick={toggleExpanded}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{expanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if}
+15
View File
@@ -3,6 +3,7 @@
let timeUntilNext = $state(""); let timeUntilNext = $state("");
let newVerseReady = $state(false); let newVerseReady = $state(false);
let showEncouragement = $state(false);
let intervalId: number | null = null; let intervalId: number | null = null;
let targetTime = 0; let targetTime = 0;
@@ -41,6 +42,13 @@
initTarget(); initTarget();
updateTimer(); updateTimer();
intervalId = window.setInterval(updateTimer, 1000); intervalId = window.setInterval(updateTimer, 1000);
const winCount = Object.keys(localStorage).filter(
(k) =>
k.startsWith("bibdle-win-tracked-") &&
localStorage.getItem(k) === "true",
).length;
showEncouragement = winCount < 3;
}); });
onDestroy(() => { onDestroy(() => {
@@ -77,6 +85,13 @@
> >
{timeUntilNext} {timeUntilNext}
</p> </p>
{#if showEncouragement}
<p
class="text-xs text-center uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold px-4 mt-3"
>
Come back tomorrow for a new verse!
</p>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
+6
View File
@@ -69,6 +69,12 @@
> >
📊 View Stats 📊 View Stats
</a> </a>
<a
href="/progress"
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium shadow-md"
>
📈 View Progress
</a>
{#if user} {#if user}
<form method="POST" action="/auth/logout" use:enhance class="w-full"> <form method="POST" action="/auth/logout" use:enhance class="w-full">
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
let { guessCount }: { guessCount: number } = $props();
let promptText = $state("What book of the Bible is this verse from?");
let visible = $state(true);
$effect(() => {
let fadeOutId: ReturnType<typeof setTimeout>;
let changeId: ReturnType<typeof setTimeout>;
function animateTo(newText: string, delay = 0) {
fadeOutId = setTimeout(() => {
visible = false;
changeId = setTimeout(() => {
promptText = newText;
visible = true;
}, 300);
}, delay);
}
if (guessCount === 0) {
animateTo("What book of the Bible is this verse from?");
} else {
animateTo("Guess again", 2100);
}
return () => {
clearTimeout(fadeOutId);
clearTimeout(changeId);
};
});
</script>
<p
class="big-text text-center text-gray-800 dark:text-gray-100 mb-6 px-4"
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
>
{promptText}
</p>
+172 -22
View File
@@ -1,14 +1,20 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks } from "$lib/types/bible"; import { bibleBooks } from "$lib/types/bible";
import { getFirstLetter, type Guess } from "$lib/utils/game"; import { getFirstLetter, type Guess } from "$lib/utils/game";
import Container from "./Container.svelte";
let { let {
guesses, guesses,
correctBookId, correctBookId,
}: { guesses: Guess[]; correctBookId: string } = $props(); minimized = false,
}: { guesses: Guess[]; correctBookId: string; minimized?: boolean } = $props();
let hasGuesses = $derived(guesses.length > 0); let hasGuesses = $derived(guesses.length > 0);
let showMinimized = $derived(minimized);
let expanded = $state(false);
$effect(() => {
if (!minimized) expanded = false;
});
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string { function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
if (isCorrect) return "bg-green-500 border-green-600"; if (isCorrect) return "bg-green-500 border-green-600";
@@ -16,6 +22,13 @@
return "bg-red-500 border-red-600"; return "bg-red-500 border-red-600";
} }
function getBookBoxStyle(guess: Guess): string {
if (guess.book.id === correctBookId) {
return "background-color: #22c55e; border-color: #16a34a;";
}
return "background-color: #ef4444; border-color: #dc2626;";
}
function getBoxContent( function getBoxContent(
guess: Guess, guess: Guess,
column: "book" | "firstLetter" | "testament" | "section", column: "book" | "firstLetter" | "testament" | "section",
@@ -64,17 +77,7 @@
} }
</script> </script>
{#if !hasGuesses} {#if hasGuesses}
<Container class="p-6 text-center">
<h2 class="font-triodion text-xl italic mb-3 text-gray-800 dark:text-gray-100">
Instructions
</h2>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed italic">
Guess what book of the bible you think the verse is from. You will
get clues to help you after each guess.
</p>
</Container>
{:else}
<div class="space-y-3"> <div class="space-y-3">
<!-- Column Headers --> <!-- Column Headers -->
<div <div
@@ -102,14 +105,85 @@
</div> </div>
</div> </div>
{#each guesses as guess, rowIndex (guess.book.id)} {#if showMinimized && !expanded}
<!-- Minimized view: first guess, divider, last two guesses -->
<!-- First guess (no animation since post-win) -->
{@const firstGuess = guesses[0]}
<div class="flex gap-2 justify-center">
<!-- Testament Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
firstGuess.testamentMatch,
)}"
style="animation: none; opacity: 1; transform: none;"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(firstGuess, "testament")}</span
>
</div>
<!-- Section Column -->
<div
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
firstGuess.sectionMatch,
firstGuess.adjacent,
)}"
style="animation: none; opacity: 1; transform: none;"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(firstGuess, "section")}
{#if firstGuess.adjacent}
‼️
{/if}
</span>
</div>
<!-- First Letter Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
firstGuess.firstLetterMatch,
)}"
style="animation: none; opacity: 1; transform: none;"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(firstGuess, "firstLetter")}</span
>
</div>
<!-- Book Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(firstGuess)}"
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(firstGuess, "book")}</span
>
</div>
</div>
<!-- Expand/collapse divider -->
<button
type="button"
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
onclick={() => (expanded = true)}
>
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
expand guesses ▼
</span>
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
</button>
<!-- Last two guesses (no animation since post-win) -->
{#each guesses.slice(-2) as guess (guess.book.id)}
<div class="flex gap-2 justify-center"> <div class="flex gap-2 justify-center">
<!-- Testament Column --> <!-- Testament Column -->
<div <div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor( class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.testamentMatch, guess.testamentMatch,
)}" )}"
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms" style="animation: none; opacity: 1; transform: none;"
> >
<span class="text-center leading-tight px-1 text-shadow-sm" <span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "testament")}</span >{getBoxContent(guess, "testament")}</span
@@ -122,7 +196,7 @@
guess.sectionMatch, guess.sectionMatch,
guess.adjacent, guess.adjacent,
)}" )}"
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms" style="animation: none; opacity: 1; transform: none;"
> >
<span class="text-center leading-tight px-1 text-shadow-sm" <span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "section")} >{getBoxContent(guess, "section")}
@@ -137,7 +211,7 @@
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor( class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.firstLetterMatch, guess.firstLetterMatch,
)}" )}"
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms" style="animation: none; opacity: 1; transform: none;"
> >
<span class="text-center leading-tight px-1 text-shadow-sm" <span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "firstLetter")}</span >{getBoxContent(guess, "firstLetter")}</span
@@ -146,10 +220,8 @@
<!-- Book Column --> <!-- Book Column -->
<div <div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor( class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
guess.book.id === correctBookId, style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(guess)}"
)}"
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
> >
<span class="text-center leading-tight px-1 text-shadow-lg" <span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span >{getBoxContent(guess, "book")}</span
@@ -157,8 +229,86 @@
</div> </div>
</div> </div>
{/each} {/each}
{:else}
<!-- Full view: all guesses -->
{#each guesses as guess, rowIndex (guess.book.id)}
<div class="flex gap-2 justify-center">
<!-- Testament Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.testamentMatch,
)}"
style={showMinimized
? "animation: none; opacity: 1; transform: none;"
: `animation-delay: ${rowIndex * 1000 + 0 * 500}ms`}
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "testament")}</span
>
</div>
<!-- Section Column -->
<div
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.sectionMatch,
guess.adjacent,
)}"
style={showMinimized
? "animation: none; opacity: 1; transform: none;"
: `animation-delay: ${rowIndex * 1000 + 1 * 500}ms`}
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "section")}
{#if guess.adjacent}
‼️
{/if}
</span>
</div>
<!-- First Letter Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.firstLetterMatch,
)}"
style={showMinimized
? "animation: none; opacity: 1; transform: none;"
: `animation-delay: ${rowIndex * 1000 + 2 * 500}ms`}
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "firstLetter")}</span
>
</div>
<!-- Book Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
style={showMinimized
? `animation: none; opacity: 1; transform: none; ${getBookBoxStyle(guess)}`
: `animation-delay: ${rowIndex * 1000 + 3 * 500}ms; ${getBookBoxStyle(guess)}`}
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span
>
</div>
</div>
{#if showMinimized && expanded && rowIndex === 0}
<!-- Collapse divider shown right below the final (correct) guess -->
<button
type="button"
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
onclick={() => (expanded = false)}
>
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
collapse guesses ▲
</span>
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
</button>
{/if}
{/each}
{/if}
</div> </div>
<!-- </div> -->
{/if} {/if}
<style> <style>
@@ -0,0 +1,25 @@
<script lang="ts">
import Container from "$lib/components/Container.svelte";
interface Props {
emoji: string;
value: string;
label: string;
colorClass: string;
suffix?: string;
}
let { emoji, value, label, colorClass, suffix }: Props = $props();
</script>
<Container class="p-4 md:p-6 w-full">
<div class="text-center w-full">
<div class="text-2xl md:text-3xl mb-1">{emoji}</div>
<div class="text-2xl md:text-3xl font-bold {colorClass} mb-1">
{value}{#if suffix}<span class="text-base font-normal text-gray-400"
>&nbsp;{suffix}</span
>{/if}
</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">{label}</div>
</div>
</Container>
+69 -23
View File
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible"; import {
bibleBooks,
type BibleBook,
type BibleSection,
type Testament,
} from "$lib/types/bible";
import { SvelteSet } from "svelte/reactivity"; import { SvelteSet } from "svelte/reactivity";
let { let {
@@ -17,13 +22,13 @@
type DisplayMode = "simple" | "testament" | "sections"; type DisplayMode = "simple" | "testament" | "sections";
const displayMode = $derived<DisplayMode>( const displayMode = $derived<DisplayMode>(
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple" guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
); );
const filteredBooks = $derived( const filteredBooks = $derived(
bibleBooks.filter((book) => bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()) book.name.toLowerCase().includes(searchQuery.toLowerCase()),
) ),
); );
type SimpleGroup = { books: BibleBook[] }; type SimpleGroup = { books: BibleBook[] };
@@ -44,7 +49,7 @@
const simpleGroup = $derived.by<SimpleGroup>(() => { const simpleGroup = $derived.by<SimpleGroup>(() => {
const sorted = [...filteredBooks].sort((a, b) => const sorted = [...filteredBooks].sort((a, b) =>
a.name.localeCompare(b.name) a.name.localeCompare(b.name),
); );
return { books: sorted }; return { books: sorted };
}); });
@@ -58,10 +63,18 @@
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
const groups: TestamentGroup[] = []; const groups: TestamentGroup[] = [];
if (old.length > 0) { if (old.length > 0) {
groups.push({ testament: "old", label: "Old Testament", books: old }); groups.push({
testament: "old",
label: "Old Testament",
books: old,
});
} }
if (newT.length > 0) { if (newT.length > 0) {
groups.push({ testament: "new", label: "New Testament", books: newT }); groups.push({
testament: "new",
label: "New Testament",
books: newT,
});
} }
return groups; return groups;
}); });
@@ -69,13 +82,17 @@
const sectionGroups = $derived.by<SectionGroup[]>(() => { const sectionGroups = $derived.by<SectionGroup[]>(() => {
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once // Build an ordered list of (testament, section) pairs by iterating bibleBooks once
const seenKeys: Record<string, true> = {}; const seenKeys: Record<string, true> = {};
const orderedPairs: { testament: Testament; section: BibleSection }[] = []; const orderedPairs: { testament: Testament; section: BibleSection }[] =
[];
for (const book of bibleBooks) { for (const book of bibleBooks) {
const key = `${book.testament}:${book.section}`; const key = `${book.testament}:${book.section}`;
if (!seenKeys[key]) { if (!seenKeys[key]) {
seenKeys[key] = true; seenKeys[key] = true;
orderedPairs.push({ testament: book.testament, section: book.section }); orderedPairs.push({
testament: book.testament,
section: book.section,
});
} }
} }
@@ -84,7 +101,9 @@
for (const pair of orderedPairs) { for (const pair of orderedPairs) {
const books = filteredBooks.filter( const books = filteredBooks.filter(
(b) => b.testament === pair.testament && b.section === pair.section (b) =>
b.testament === pair.testament &&
b.section === pair.section,
); );
if (books.length === 0) continue; if (books.length === 0) continue;
@@ -94,7 +113,9 @@
groups.push({ groups.push({
testament: pair.testament, testament: pair.testament,
testamentLabel: testamentLabel:
pair.testament === "old" ? "Old Testament" : "New Testament", pair.testament === "old"
? "Old Testament"
: "New Testament",
showTestamentHeader, showTestamentHeader,
section: pair.section, section: pair.section,
books, books,
@@ -122,7 +143,8 @@
} }
} }
const showBanner = $derived(guessCount >= 3); // const showBanner = $derived(guessCount >= 3);
const showBanner = false;
const bannerIsIndigo = $derived(guessCount >= 9); const bannerIsIndigo = $derived(guessCount >= 9);
</script> </script>
@@ -206,7 +228,9 @@
tabindex={guessedIds.has(book.id) ? -1 : 0} tabindex={guessedIds.has(book.id) ? -1 : 0}
> >
<span <span
class="font-semibold dark:text-gray-100 {guessedIds.has(book.id) class="font-semibold dark:text-gray-100 {guessedIds.has(
book.id,
)
? 'line-through text-gray-400 dark:text-gray-500' ? 'line-through text-gray-400 dark:text-gray-500'
: ''}" : ''}"
> >
@@ -226,21 +250,30 @@
> >
{group.label} {group.label}
</span> </span>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div> <div
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
></div>
</div> </div>
<ul> <ul>
{#each group.books as book (book.id)} {#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}> <li
role="option"
aria-selected={guessedIds.has(book.id)}
>
<button <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 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) {guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none' ? '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'}" : 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)} onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0} tabindex={guessedIds.has(book.id)
? -1
: 0}
> >
<span <span
class="font-semibold {guessedIds.has(book.id) class="font-semibold {guessedIds.has(
book.id,
)
? 'line-through text-gray-400 dark:text-gray-500' ? 'line-through text-gray-400 dark:text-gray-500'
: ''}" : ''}"
> >
@@ -264,7 +297,9 @@
> >
{group.testamentLabel} {group.testamentLabel}
</span> </span>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div> <div
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
></div>
</div> </div>
{/if} {/if}
<div <div
@@ -275,21 +310,30 @@
> >
{group.section} {group.section}
</span> </span>
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div> <div
class="flex-1 h-px bg-gray-100 dark:bg-gray-600"
></div>
</div> </div>
<ul> <ul>
{#each group.books as book (book.id)} {#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}> <li
role="option"
aria-selected={guessedIds.has(book.id)}
>
<button <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 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) {guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none' ? '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'}" : 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)} onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0} tabindex={guessedIds.has(book.id)
? -1
: 0}
> >
<span <span
class="font-semibold {guessedIds.has(book.id) class="font-semibold {guessedIds.has(
book.id,
)
? 'line-through text-gray-400 dark:text-gray-500' ? 'line-through text-gray-400 dark:text-gray-500'
: ''}" : ''}"
> >
@@ -304,6 +348,8 @@
{/if} {/if}
</ul> </ul>
{:else if searchQuery} {:else if searchQuery}
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 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} {/if}
</div> </div>
+1 -4
View File
@@ -11,7 +11,6 @@
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Bluesky" aria-label="Follow on Bluesky"
data-umami-event="Bluesky clicked" data-umami-event="Bluesky clicked"
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
> >
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" /> <img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
</a> </a>
@@ -25,7 +24,6 @@
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Twitter" aria-label="Follow on Twitter"
data-umami-event="Twitter clicked" data-umami-event="Twitter clicked"
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
> >
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" /> <img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
</a> </a>
@@ -33,11 +31,10 @@
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> --> <div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
<a <a
href="mailto:george+bibdle@silentsummit.co" href="mailto:george@snail.city"
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email" aria-label="Send email"
data-umami-event="Email clicked" data-umami-event="Email clicked"
onclick={() => (window as any).rybbit?.event("Email clicked")}
> >
<svg <svg
class="w-8 h-8 text-gray-700 dark:text-gray-300" class="w-8 h-8 text-gray-700 dark:text-gray-300"
+995
View File
@@ -0,0 +1,995 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { fly } from "svelte/transition";
import { bibleBooks } from "$lib/types/bible";
let {
isLoggedIn = false,
anonymousId = "",
localDate = "",
}: {
isLoggedIn?: boolean;
anonymousId?: string;
localDate?: string;
} = $props();
// ── Status (logged-in state) ───────────────────────────────────────────
type Status = {
canSubmit: boolean;
cooldownEndsAt: number | null;
lastSubmission: { scheduledDate: string; reference: string } | null;
upcoming: { scheduledDate: string; reference: string }[];
};
let status = $state<Status | null>(null);
let statusError = $state<string | null>(null);
// ── Bible structure (Book → chapters → verse counts) ──────────────────
type Structure = { bookId: string; chapters: number[] }[];
let structure = $state<Structure | null>(null);
// ── Selector state ─────────────────────────────────────────────────────
let selectedBookId = $state<string>("");
let selectedChapter = $state<number>(0);
let selectedVerse = $state<number>(0);
// ── Preview state ──────────────────────────────────────────────────────
type Preview = {
windowVerses: string[];
reference: string;
bookId: string;
selectedVerse: number;
};
let preview = $state<Preview | null>(null);
let previewLoading = $state(false);
// ── Submission state ───────────────────────────────────────────────────
let submitting = $state(false);
let submitError = $state<string | null>(null);
let submitResult = $state<{ scheduledDate: string; reference: string } | null>(
null,
);
// ── UI state ───────────────────────────────────────────────────────────
let expanded = $state(false);
let countdownText = $state("");
let countdownId: number | null = null;
// Book list grouped by Testament for the <select> optgroups.
const oldBooks = $derived(bibleBooks.filter((b) => b.testament === "old"));
const newBooks = $derived(bibleBooks.filter((b) => b.testament === "new"));
// Chapter list for the currently selected book (1..N).
const chapterCount = $derived(
structure && selectedBookId
? structure.find((b) => b.bookId === selectedBookId)?.chapters.length ??
0
: 0,
);
// Verse count for the currently selected chapter.
const verseCount = $derived(
structure && selectedBookId && selectedChapter
? structure.find((b) => b.bookId === selectedBookId)?.chapters[
selectedChapter - 1
] ?? 0
: 0,
);
// Whether the user is currently on cooldown (only meaningful when logged in).
const onCooldown = $derived(
!!status && !status.canSubmit && status.cooldownEndsAt !== null,
);
// Debounce handle for the live preview fetch.
let previewTimer: ReturnType<typeof setTimeout> | null = null;
function trackEvent(name: string) {
try {
(window as any).rybbit?.event?.(name);
} catch {
/* noop */
}
}
function fmtDateLong(dateStr: string): string {
// dateStr is YYYY-MM-DD; format as "August 24, 2026" in UTC to avoid drift.
const d = new Date(dateStr + "T00:00:00Z");
return d.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
function fmtDateVague(dateStr: string): string {
// dateStr is YYYY-MM-DD. Return a deliberately vague timeframe instead of
// revealing the exact scheduled date.
const scheduled = new Date(dateStr + "T00:00:00Z");
const now = new Date();
const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
const diffDays = Math.round(
(scheduled.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays <= 14) return "a few days from now";
if (diffDays <= 60) return "a few weeks from now";
return "within the next few months";
}
async function loadStatus() {
if (!isLoggedIn || !localDate) return;
try {
const res = await fetch(
`/api/submit-verse/status?localDate=${encodeURIComponent(localDate)}`,
);
if (res.status === 401) {
status = null;
return;
}
if (!res.ok) {
statusError = "Couldn't load submission status.";
return;
}
status = await res.json();
} catch {
statusError = "Couldn't load submission status.";
}
}
async function loadStructure() {
if (structure) return;
try {
const res = await fetch("/api/bible/structure");
if (!res.ok) return;
const data = await res.json();
structure = data;
// Default selection: first book, chapter 1, verse 1.
if (data.length > 0 && !selectedBookId) {
selectedBookId = data[0].bookId;
selectedChapter = 1;
selectedVerse = 1;
}
} catch {
/* noop */
}
}
function fetchPreview() {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(async () => {
if (!selectedBookId || !selectedChapter || !selectedVerse) {
preview = null;
return;
}
previewLoading = true;
try {
const res = await fetch(
`/api/verse-window?bookId=${encodeURIComponent(
selectedBookId,
)}&chapter=${selectedChapter}&verse=${selectedVerse}`,
);
if (res.ok) {
preview = await res.json();
}
} catch {
/* noop */
} finally {
previewLoading = false;
}
}, 250);
}
function onBookChange() {
selectedChapter = 1;
selectedVerse = 1;
submitError = null;
fetchPreview();
}
function onChapterChange() {
selectedVerse = 1;
submitError = null;
fetchPreview();
}
function onVerseChange() {
submitError = null;
fetchPreview();
}
async function handleSubmit() {
if (!isLoggedIn) return;
submitting = true;
submitError = null;
try {
const res = await fetch("/api/submit-verse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bookId: selectedBookId,
chapter: selectedChapter,
verse: selectedVerse,
localDate,
}),
});
const data = await res.json();
if (!res.ok) {
if (res.status === 429 && data?.cooldownEndsAt) {
// Refresh status so the cooldown UI kicks in.
await loadStatus();
submitError = "You're on cooldown — try again later.";
} else {
submitError = data?.error ?? "Submission failed.";
}
return;
}
submitResult = {
scheduledDate: data.scheduledDate,
reference: data.reference,
};
trackEvent("Submit a verse");
// Refresh status so the cooldown reflects the new submission.
await loadStatus();
} catch {
submitError = "Network error — please try again.";
} finally {
submitting = false;
}
}
function updateCountdown() {
if (!status?.cooldownEndsAt) {
countdownText = "";
return;
}
const diff = status.cooldownEndsAt - Date.now();
if (diff <= 0) {
countdownText = "";
// Cooldown elapsed — refresh status once.
loadStatus();
if (countdownId) {
clearInterval(countdownId);
countdownId = null;
}
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
countdownText =
(days > 0 ? `${days}d ` : "") +
`${hours.toString().padStart(2, "0")}h ${minutes
.toString()
.padStart(2, "0")}m`;
}
onMount(() => {
if (isLoggedIn) {
loadStatus();
loadStructure();
}
});
// Start/stop the countdown timer as cooldown state changes.
$effect(() => {
if (onCooldown && status?.cooldownEndsAt && !countdownId) {
updateCountdown();
countdownId = window.setInterval(updateCountdown, 1000);
} else if (!onCooldown && countdownId) {
clearInterval(countdownId);
countdownId = null;
countdownText = "";
}
});
// (Re)load status + structure when the user logs in.
$effect(() => {
if (isLoggedIn) {
loadStatus();
loadStructure();
} else {
status = null;
structure = null;
expanded = false;
submitResult = null;
}
});
onDestroy(() => {
if (countdownId) clearInterval(countdownId);
if (previewTimer) clearTimeout(previewTimer);
});
</script>
<div class="signin-prompt submit-verse-wrap">
{#if submitResult}
<!-- ── Confirmation state (replaces the panel after success) ──────── -->
<div class="confirm-card" in:fly={{ y: -8, duration: 220 }}>
<p class="confirm-title">
✅ Your verse is scheduled for
<strong>{fmtDateVague(submitResult.scheduledDate)}</strong>
</p>
<p class="confirm-ref">{submitResult.reference}</p>
<button
type="button"
class="submit-another-btn"
onclick={() => {
submitResult = null;
expanded = false;
}}
>
Done
</button>
</div>
{:else if !isLoggedIn}
<!-- ── State 1: logged out — sign-in dropdown ─────────────────────── -->
<button
type="button"
class="progress-btn w-full"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
data-umami-event="Submit a verse (logged out)"
>
<span>✨ Submit a verse</span>
<svg
class="progress-chev"
class:open={expanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{#if expanded}
<p class="signin-text text-gray-800 dark:text-gray-300">
Sign in to submit a verse for a future day
</p>
<form method="POST" action="/auth/apple" class="w-full">
<input type="hidden" name="anonymousId" value={anonymousId} />
<button
type="submit"
class="apple-signin-btn"
data-umami-event="Sign in with Apple"
>
<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>
<form method="POST" action="/auth/google" class="w-full">
<input type="hidden" name="anonymousId" value={anonymousId} />
<button
type="submit"
class="google-signin-btn"
data-umami-event="Sign in with Google"
>
<svg
class="google-icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</button>
</form>
{/if}
{:else if onCooldown}
<!-- ── State 2: logged in, cooldown active — disabled + countdown on button ─── -->
<button
type="button"
class="progress-btn w-full progress-btn-disabled"
disabled
aria-disabled="true"
>
<!-- <span>✨ Submit a verse</span> -->
{#if countdownText}
<span class="countdown-inline" aria-hidden="true"
>⏳ {countdownText}</span
>
{:else}
<span class="lock-icon" aria-hidden="true"></span>
{/if}
</button>
{:else}
<!-- ── State 3: logged in, can submit — selector + preview ────────── -->
<button
type="button"
class="progress-btn w-full"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
data-umami-event="Submit a verse (logged in)"
>
<span>✨ Submit a verse</span>
<svg
class="progress-chev"
class:open={expanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{#if expanded}
<div class="submit-panel" in:fly={{ y: -8, duration: 220 }}>
{#if statusError}
<p class="panel-error">{statusError}</p>
{/if}
{#if !structure}
<p class="panel-hint">Loading Bible structure…</p>
{:else}
<div class="select-row">
<label class="select-label">
<span class="select-cap">Book</span>
<select
class="cascading-select"
value={selectedBookId}
onchange={(e) => {
selectedBookId = e.currentTarget.value;
onBookChange();
}}
>
<optgroup label="Old Testament">
{#each oldBooks as book (book.id)}
<option value={book.id}>{book.name}</option>
{/each}
</optgroup>
<optgroup label="New Testament">
{#each newBooks as book (book.id)}
<option value={book.id}>{book.name}</option>
{/each}
</optgroup>
</select>
</label>
<label class="select-label">
<span class="select-cap">Chapter</span>
<select
class="cascading-select"
value={selectedChapter}
onchange={(e) => {
selectedChapter = Number(e.currentTarget.value);
onChapterChange();
}}
>
{#if chapterCount === 0}
<option value={0}>—</option>
{:else}
{#each Array(chapterCount) as _, i (i)}
<option value={i + 1}>{i + 1}</option>
{/each}
{/if}
</select>
</label>
<label class="select-label">
<span class="select-cap">Verse</span>
<select
class="cascading-select"
value={selectedVerse}
onchange={(e) => {
selectedVerse = Number(e.currentTarget.value);
onVerseChange();
}}
>
{#if verseCount === 0}
<option value={0}>—</option>
{:else}
{#each Array(verseCount) as _, i (i)}
<option value={i + 1}>{i + 1}</option>
{/each}
{/if}
</select>
</label>
</div>
<!-- Live 3-verse preview -->
<div class="preview-card">
{#if previewLoading}
<p class="preview-hint">Loading preview…</p>
{:else if preview}
<p class="preview-ref">{preview.reference}</p>
<div class="preview-verses">
{#each preview.windowVerses as verseText, i (i)}
<p class="preview-verse">{verseText}</p>
{/each}
</div>
{:else}
<p class="preview-hint">Select a verse to preview.</p>
{/if}
</div>
{#if submitError}
<p class="panel-error">{submitError}</p>
{/if}
<button
type="button"
class="submit-btn"
disabled={submitting ||
!selectedBookId ||
!selectedChapter ||
!selectedVerse ||
!preview}
onclick={handleSubmit}
>
{submitting ? "Scheduling…" : "Submit verse"}
</button>
{#if status?.upcoming && status.upcoming.length > 0}
<p class="upcoming-text">
Your upcoming verse{status.upcoming.length > 1
? "s"
: ""}:
{#each status.upcoming as u, i (i)}
{#if i > 0}<span class="upcoming-sep">·</span>{/if}
<span class="upcoming-item"
>{u.reference} ({fmtDateVague(u.scheduledDate)})</span
>
{/each}
</p>
{/if}
{/if}
</div>
{/if}
{/if}
</div>
<style>
.submit-verse-wrap {
gap: 0.75rem;
}
/* Reuse WinScreen's neobrutalist .progress-btn base (scoped here too). */
:global(.submit-verse-wrap .progress-btn) {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.85rem 1.25rem;
background: #fff;
color: #111;
border: 2px solid #000;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 700;
text-decoration: none;
cursor: pointer;
box-shadow: 6px 6px 0 0 #000;
transition:
transform 80ms ease,
box-shadow 80ms ease;
}
:global(.submit-verse-wrap .progress-btn:hover) {
transform: translate(-2px, -2px);
box-shadow: 8px 8px 0 0 #000;
}
:global(.submit-verse-wrap .progress-btn:active) {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 0 #000;
}
:global(.submit-verse-wrap .progress-chev) {
width: 1.25rem;
height: 1.25rem;
transition: transform 200ms ease;
}
:global(.submit-verse-wrap .progress-chev.open) {
transform: rotate(180deg);
}
@media (prefers-color-scheme: dark) {
:global(.submit-verse-wrap .progress-btn) {
background: #111827;
color: #f9fafb;
border-color: #fff;
box-shadow: 6px 6px 0 0 #fff;
}
:global(.submit-verse-wrap .progress-btn:hover) {
box-shadow: 8px 8px 0 0 #fff;
}
:global(.submit-verse-wrap .progress-btn:active) {
box-shadow: 2px 2px 0 0 #fff;
}
}
/* Disabled (cooldown) variant. */
:global(.submit-verse-wrap .progress-btn-disabled) {
opacity: 0.55;
cursor: not-allowed;
box-shadow: 4px 4px 0 0 #000;
}
:global(.submit-verse-wrap .progress-btn-disabled:hover),
:global(.submit-verse-wrap .progress-btn-disabled:active) {
transform: none;
box-shadow: 4px 4px 0 0 #000;
}
@media (prefers-color-scheme: dark) {
:global(.submit-verse-wrap .progress-btn-disabled) {
box-shadow: 4px 4px 0 0 #fff;
}
:global(.submit-verse-wrap .progress-btn-disabled:hover),
:global(.submit-verse-wrap .progress-btn-disabled:active) {
box-shadow: 4px 4px 0 0 #fff;
}
}
.lock-icon {
font-size: 1rem;
}
.countdown-inline {
font-size: 0.85rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
opacity: 0.85;
}
.signin-text {
font-size: 0.85rem;
text-align: center;
font-weight: 500;
}
/* ── Apple / Google sign-in buttons (mirror WinScreen) ── */
:global(.submit-verse-wrap .apple-signin-btn),
:global(.submit-verse-wrap .google-signin-btn) {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
width: 100%;
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;
}
:global(.submit-verse-wrap .apple-signin-btn) {
margin-bottom: 0.6rem;
}
:global(.submit-verse-wrap .apple-signin-btn:hover),
:global(.submit-verse-wrap .google-signin-btn:hover) {
background: #222;
transform: translateY(-1px);
}
:global(.submit-verse-wrap .apple-signin-btn:active),
:global(.submit-verse-wrap .google-signin-btn:active) {
background: #111;
transform: scale(0.98);
}
@media (prefers-color-scheme: dark) {
:global(.submit-verse-wrap .apple-signin-btn),
:global(.submit-verse-wrap .google-signin-btn) {
background: #fff;
color: #000;
}
:global(.submit-verse-wrap .apple-signin-btn:hover),
:global(.submit-verse-wrap .google-signin-btn:hover) {
background: #e5e5e5;
}
:global(.submit-verse-wrap .apple-signin-btn:active),
:global(.submit-verse-wrap .google-signin-btn:active) {
background: #ccc;
}
}
:global(.submit-verse-wrap .apple-icon),
:global(.submit-verse-wrap .google-icon) {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
}
/* ── Submit panel ── */
.submit-panel {
display: flex;
flex-direction: column;
gap: 0.85rem;
width: 100%;
padding: 1rem;
background: oklch(94% 0.028 298.626);
border: 2px solid #000;
border-radius: 0.75rem;
box-shadow: 6px 6px 0 0 #000;
}
@media (prefers-color-scheme: dark) {
.submit-panel {
background: oklch(22% 0.025 298.626);
border-color: #fff;
box-shadow: 6px 6px 0 0 #fff;
}
}
.panel-hint,
.preview-hint {
font-size: 0.85rem;
color: #6b7280;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.panel-hint,
.preview-hint {
color: #9ca3af;
}
}
.panel-error {
font-size: 0.85rem;
color: #b91c1c;
text-align: center;
font-weight: 600;
}
@media (prefers-color-scheme: dark) {
.panel-error {
color: #f87171;
}
}
.select-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.select-label {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 5rem;
}
.select-cap {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7280;
}
@media (prefers-color-scheme: dark) {
.select-cap {
color: #9ca3af;
}
}
.cascading-select {
width: 100%;
padding: 0.5rem 0.6rem;
border: 2px solid #000;
border-radius: 0.5rem;
background: #fff;
color: #111;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
@media (prefers-color-scheme: dark) {
.cascading-select {
background: #111827;
color: #f9fafb;
border-color: #fff;
}
}
/* ── Preview card ── */
.preview-card {
padding: 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.5);
}
@media (prefers-color-scheme: dark) {
.preview-card {
border-color: rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.25);
}
}
.preview-ref {
font-size: 0.8rem;
font-weight: 700;
color: #374151;
margin-bottom: 0.4rem;
}
@media (prefers-color-scheme: dark) {
.preview-ref {
color: #d1d5db;
}
}
.preview-verses {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.preview-verse {
font-size: 0.82rem;
line-height: 1.45;
color: #4b5563;
font-style: italic;
}
@media (prefers-color-scheme: dark) {
.preview-verse {
color: #9ca3af;
}
}
/* ── Submit button (neobrutalist) ── */
.submit-btn {
padding: 0.7rem 1rem;
background: #16a34a;
color: #fff;
border: 2px solid #000;
border-radius: 0.6rem;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
box-shadow: 4px 4px 0 0 #000;
transition:
transform 80ms ease,
box-shadow 80ms ease,
background-color 120ms ease;
}
.submit-btn:hover:not(:disabled) {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 0 #000;
background: #15803d;
}
.submit-btn:active:not(:disabled) {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 0 #000;
}
.submit-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (prefers-color-scheme: dark) {
.submit-btn {
border-color: #fff;
box-shadow: 4px 4px 0 0 #fff;
}
.submit-btn:hover:not(:disabled) {
box-shadow: 6px 6px 0 0 #fff;
}
.submit-btn:active:not(:disabled) {
box-shadow: 2px 2px 0 0 #fff;
}
}
/* ── Upcoming list ── */
.upcoming-text {
font-size: 0.72rem;
color: #6b7280;
text-align: center;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
.upcoming-text {
color: #9ca3af;
}
}
.upcoming-item {
font-weight: 600;
}
.upcoming-sep {
margin: 0 0.25rem;
opacity: 0.6;
}
/* ── Confirmation card ── */
.confirm-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
width: 100%;
padding: 1.1rem;
background: oklch(94% 0.028 298.626);
border: 2px solid #000;
border-radius: 0.75rem;
box-shadow: 6px 6px 0 0 #000;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.confirm-card {
background: oklch(22% 0.025 298.626);
border-color: #fff;
box-shadow: 6px 6px 0 0 #fff;
}
}
.confirm-title {
font-size: 1rem;
font-weight: 700;
color: #111;
}
@media (prefers-color-scheme: dark) {
.confirm-title {
color: #f9fafb;
}
}
.confirm-ref {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
}
@media (prefers-color-scheme: dark) {
.confirm-ref {
color: #d1d5db;
}
}
.submit-another-btn {
padding: 0.5rem 1.1rem;
background: #fff;
color: #111;
border: 2px solid #000;
border-radius: 0.5rem;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
box-shadow: 3px 3px 0 0 #000;
transition:
transform 80ms ease,
box-shadow 80ms ease;
}
.submit-another-btn:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 0 #000;
}
.submit-another-btn:active {
transform: translate(1px, 1px);
box-shadow: 2px 2px 0 0 #000;
}
@media (prefers-color-scheme: dark) {
.submit-another-btn {
background: #111827;
color: #f9fafb;
border-color: #fff;
box-shadow: 3px 3px 0 0 #fff;
}
.submit-another-btn:hover {
box-shadow: 4px 4px 0 0 #fff;
}
.submit-another-btn:active {
box-shadow: 2px 2px 0 0 #fff;
}
}
</style>
+10 -4
View File
@@ -1,14 +1,21 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
import Container from "./Container.svelte"; import Container from "./Container.svelte";
interface VerseDisplayData {
dailyVerse: {
date: string;
reference: string;
verseText: string;
};
}
let { let {
data, data,
isWon, isWon,
blurChapter = false, blurChapter = false,
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props(); }: { data: VerseDisplayData; isWon: boolean; blurChapter?: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse); let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived( let displayReference = $derived(
blurChapter blurChapter
@@ -19,7 +26,7 @@
); );
let displayVerseText = $derived( let displayVerseText = $derived(
dailyVerse.verseText dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase()) .replace(/^([a-z])/, (c: string) => c.toUpperCase())
.replace(/[,:;-—]$/, "..."), .replace(/[,:;-—]$/, "..."),
); );
@@ -55,7 +62,6 @@
function copyVerse() { function copyVerse() {
navigator.clipboard.writeText(displayVerseText).then(() => { navigator.clipboard.writeText(displayVerseText).then(() => {
copied = true; copied = true;
(window as any).rybbit?.event("Copy Verse");
setTimeout(() => { setTimeout(() => {
copied = false; copied = false;
}, 2000); }, 2000);
+299 -23
View File
@@ -10,6 +10,7 @@
import CountdownTimer from "./CountdownTimer.svelte"; import CountdownTimer from "./CountdownTimer.svelte";
import StreakCounter from "./StreakCounter.svelte"; import StreakCounter from "./StreakCounter.svelte";
import ChapterGuess from "./ChapterGuess.svelte"; import ChapterGuess from "./ChapterGuess.svelte";
import SubmitVerse from "./SubmitVerse.svelte";
interface StatsData { interface StatsData {
solveRank: number; solveRank: number;
@@ -40,7 +41,8 @@
streak = 0, streak = 0,
streakPercentile = null, streakPercentile = null,
isLoggedIn = false, isLoggedIn = false,
anonymousId = '', anonymousId = "",
localDate = "",
}: { }: {
statsData: StatsData | null; statsData: StatsData | null;
correctBookId: string; correctBookId: string;
@@ -57,6 +59,7 @@
streakPercentile?: number | null; streakPercentile?: number | null;
isLoggedIn?: boolean; isLoggedIn?: boolean;
anonymousId?: string; anonymousId?: string;
localDate?: string;
} = $props(); } = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? ""); let bookName = $derived(getBookById(correctBookId)?.name ?? "");
@@ -68,6 +71,7 @@
let copyTracked = $state(false); let copyTracked = $state(false);
let showSnippetOption = $state(false); let showSnippetOption = $state(false);
let includeSnippet = $state(false); let includeSnippet = $state(false);
let showProgressDropdown = $state(false);
let effectiveShareText = $derived( let effectiveShareText = $derived(
includeSnippet includeSnippet
@@ -237,7 +241,9 @@
{/if} {/if}
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}> <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="big-text font-black! text-center text-gray-300!">
Share your result
</div>
<div class="chat-window"> <div class="chat-window">
<!-- Received bubble: primary action (share / copy) --> <!-- Received bubble: primary action (share / copy) -->
<div class="bubble-wrapper received-wrapper"> <div class="bubble-wrapper received-wrapper">
@@ -250,13 +256,9 @@
: "Copy to Clipboard"} : "Copy to Clipboard"}
onclick={() => { onclick={() => {
if (hasWebShare) { if (hasWebShare) {
(window as any).rybbit?.event("Share");
shareResult(effectiveShareText); shareResult(effectiveShareText);
} else { } else {
if (!copyTracked) { if (!copyTracked) {
(window as any).rybbit?.event(
"Copy to Clipboard",
);
copyTracked = true; copyTracked = true;
} }
clipboardCopy(effectiveShareText); clipboardCopy(effectiveShareText);
@@ -285,7 +287,6 @@
data-umami-event="Copy to Clipboard" data-umami-event="Copy to Clipboard"
onclick={() => { onclick={() => {
if (!copyTracked) { if (!copyTracked) {
(window as any).rybbit?.event("Copy to Clipboard");
copyTracked = true; copyTracked = true;
} }
clipboardCopy(effectiveShareText); clipboardCopy(effectiveShareText);
@@ -307,6 +308,11 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if streak >= 7}
<div class="big-text tracking-widest! font-black! text-center mt-4">
Thank you for making Bibdle part of your daily routine! —George
</div>
{/if}
</div> </div>
{#if showSnippetOption} {#if showSnippetOption}
@@ -324,23 +330,140 @@
</div> </div>
{/if} {/if}
{#if !isLoggedIn} {#if isLoggedIn}
<div class="signin-prompt"> <div class="signin-prompt">
<p class="signin-text">Sign in to save your streak &amp; see your stats</p> <a
<form method="POST" action="/auth/apple"> href="/progress"
class="progress-btn w-full"
data-umami-event="See your progress (logged in)"
>
<span>📈 See your progress</span>
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</a>
</div>
{:else}
<div class="signin-prompt">
<button
type="button"
class="progress-btn w-full"
aria-expanded={showProgressDropdown}
onclick={() => (showProgressDropdown = !showProgressDropdown)}
data-umami-event="See your progress (logged out)"
>
<span>📈 See your progress</span>
<svg class="progress-chev" class:open={showProgressDropdown} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{#if showProgressDropdown}
<p class="signin-text text-gray-800 dark:text-gray-300">
Create an account (or sign in) to track your progress
</p>
<form method="POST" action="/auth/apple" class="w-full">
<input type="hidden" name="anonymousId" value={anonymousId} /> <input type="hidden" name="anonymousId" value={anonymousId} />
<button <button
type="submit" type="submit"
class="apple-signin-btn" class="apple-signin-btn"
data-umami-event="Sign in with Apple"
> >
<svg class="apple-icon" viewBox="0 0 24 24" fill="currentColor"> <svg
<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"/> 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> </svg>
Sign in with Apple Sign in with Apple
</button> </button>
</form> </form>
<form method="POST" action="/auth/google" class="w-full">
<input type="hidden" name="anonymousId" value={anonymousId} />
<button
type="submit"
class="google-signin-btn"
data-umami-event="Sign in with Google"
>
<svg
class="google-icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</button>
</form>
{/if}
</div> </div>
{/if} {/if}
<SubmitVerse
{isLoggedIn}
{anonymousId}
{localDate}
/>
<div class="signin-prompt">
<a
href="https://discord.gg/yWQXbGK8SD"
target="_blank"
rel="noopener noreferrer"
class="discord-btn w-full"
data-umami-event="Join the BIBDLE Discord"
>
<svg
class="discord-icon"
viewBox="0 0 127.14 96.36"
fill="currentColor"
aria-hidden="true"
>
<path
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
/>
</svg>
<span>Join the BIBDLE Discord!</span>
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</a>
</div>
<div class="signin-prompt">
<a
href="https://donate.stripe.com/9B6aEZ9WgeZF3qYfF987K00"
target="_blank"
rel="noopener noreferrer"
class="progress-btn w-full"
data-umami-event="Support bibdle's development"
>
<span>❤️ Support bibdle's development</span>
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</a>
<div class="big-text font-black! text-center text-gray-300! mt-2">
BIBDLE will be ad-free forever.
</div>
</div>
</div> </div>
<style> <style>
@@ -621,34 +744,137 @@
transform: translateX(16px); transform: translateX(16px);
} }
/* ── Apple Sign In prompt ── */ /* ── See your progress button (neobrutalist) ── */
.progress-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.85rem 1.25rem;
background: #fff;
color: #111;
border: 2px solid #000;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 700;
text-decoration: none;
cursor: pointer;
box-shadow: 6px 6px 0 0 #000;
transition:
transform 80ms ease,
box-shadow 80ms ease;
}
.progress-btn:hover {
transform: translate(-2px, -2px);
box-shadow: 8px 8px 0 0 #000;
}
.progress-btn:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 0 #000;
}
.progress-chev {
width: 1.25rem;
height: 1.25rem;
transition: transform 200ms ease;
}
.progress-chev.open {
transform: rotate(180deg);
}
@media (prefers-color-scheme: dark) {
.progress-btn {
background: #111827;
color: #f9fafb;
border-color: #fff;
box-shadow: 6px 6px 0 0 #fff;
}
.progress-btn:hover {
box-shadow: 8px 8px 0 0 #fff;
}
.progress-btn:active {
box-shadow: 2px 2px 0 0 #fff;
}
}
.signin-prompt { .signin-prompt {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 1rem 0 0.25rem; /*padding: 1rem 0 0.25rem;*/
}
/* ── Discord button (neobrutalist, discord purple) ── */
.discord-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.85rem 1.25rem;
background: #5865f2;
color: #fff;
border: 2px solid #000;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 700;
text-decoration: none;
cursor: pointer;
box-shadow: 6px 6px 0 0 #000;
transition:
transform 80ms ease,
box-shadow 80ms ease,
background-color 120ms ease;
}
.discord-btn:hover {
transform: translate(-2px, -2px);
box-shadow: 8px 8px 0 0 #000;
background: #4752c4;
}
.discord-btn:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 0 #000;
background: #3c45a5;
}
@media (prefers-color-scheme: dark) {
.discord-btn {
border-color: #fff;
box-shadow: 6px 6px 0 0 #fff;
}
.discord-btn:hover {
box-shadow: 8px 8px 0 0 #fff;
}
.discord-btn:active {
box-shadow: 2px 2px 0 0 #fff;
}
}
.discord-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
} }
.signin-text { .signin-text {
font-size: 0.85rem; font-size: 0.85rem;
color: #555;
text-align: center; text-align: center;
font-weight: 500; font-weight: 500;
} }
@media (prefers-color-scheme: dark) {
.signin-text {
color: #aaa;
}
}
.apple-signin-btn { .apple-signin-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.6rem 1.5rem; padding: 0.6rem 1rem;
width: 100%;
margin-bottom: 0.6rem;
background: #000; background: #000;
color: #fff; color: #fff;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -656,7 +882,9 @@
font-weight: 600; font-weight: 600;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: background 150ms ease, transform 80ms ease; transition:
background 150ms ease,
transform 80ms ease;
} }
.apple-signin-btn:hover { .apple-signin-btn:hover {
@@ -687,4 +915,52 @@
height: 1.1rem; height: 1.1rem;
flex-shrink: 0; flex-shrink: 0;
} }
.google-signin-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
width: 100%;
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;
}
.google-signin-btn:hover {
background: #222;
transform: translateY(-1px);
}
.google-signin-btn:active {
background: #111;
transform: scale(0.98);
}
@media (prefers-color-scheme: dark) {
.google-signin-btn {
background: #fff;
color: #000;
}
.google-signin-btn:hover {
background: #e5e5e5;
}
.google-signin-btn:active {
background: #ccc;
}
}
.google-icon {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
}
</style> </style>
+8
View File
@@ -0,0 +1,8 @@
// Single source of truth for the admin account that may view submitter
// attribution on /scheduled-verses. The public game never surfaces submitter
// identity; this email is the sole exception (see spec "Admin /scheduled-verses").
export const ADMIN_EMAIL = 'geohpowell@gmail.com';
export function isAdmin(email: string | null | undefined): boolean {
return !!email && email.toLowerCase() === ADMIN_EMAIL;
}
+6 -5
View File
@@ -1,4 +1,4 @@
import type { RequestEvent } from '@sveltejs/kit'; import type { Cookies } from '@sveltejs/kit';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { testDb as db } from '$lib/server/db/test'; import { testDb as db } from '$lib/server/db/test';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
@@ -64,15 +64,15 @@ export async function invalidateSession(sessionId: string) {
await db.delete(table.session).where(eq(table.session.id, sessionId)); await db.delete(table.session).where(eq(table.session.id, sessionId));
} }
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, { cookies.set(sessionCookieName, token, {
expires: expiresAt, expires: expiresAt,
path: '/' path: '/'
}); });
} }
export function deleteSessionTokenCookie(event: RequestEvent) { export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
event.cookies.delete(sessionCookieName, { cookies.delete(sessionCookieName, {
path: '/' path: '/'
}); });
} }
@@ -99,6 +99,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
email, email,
passwordHash, passwordHash,
appleId: null, appleId: null,
googleId: null,
firstName: firstName || null, firstName: firstName || null,
lastName: lastName || null, lastName: lastName || null,
isPrivate: false isPrivate: false
+12 -6
View File
@@ -1,4 +1,4 @@
import type { RequestEvent } from '@sveltejs/kit'; import type { Cookies, RequestEvent } from '@sveltejs/kit';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
@@ -28,7 +28,7 @@ export async function validateSessionToken(token: string) {
const [result] = await db const [result] = await db
.select({ .select({
// Adjust user table here to tweak returned data // Adjust user table here to tweak returned data
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId }, user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId, googleId: table.user.googleId },
session: table.session session: table.session
}) })
.from(table.session) .from(table.session)
@@ -64,15 +64,15 @@ export async function invalidateSession(sessionId: string) {
await db.delete(table.session).where(eq(table.session.id, sessionId)); await db.delete(table.session).where(eq(table.session.id, sessionId));
} }
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, { cookies.set(sessionCookieName, token, {
expires: expiresAt, expires: expiresAt,
path: '/' path: '/'
}); });
} }
export function deleteSessionTokenCookie(event: RequestEvent) { export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
event.cookies.delete(sessionCookieName, { cookies.delete(sessionCookieName, {
path: '/' path: '/'
}); });
} }
@@ -99,6 +99,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
email, email,
passwordHash, passwordHash,
appleId: null, appleId: null,
googleId: null,
firstName: firstName || null, firstName: firstName || null,
lastName: lastName || null, lastName: lastName || null,
isPrivate: false isPrivate: false
@@ -117,6 +118,11 @@ export async function getUserByAppleId(appleId: string) {
return user || null; return user || null;
} }
export async function getUserByGoogleId(googleId: string) {
const [user] = await db.select().from(table.user).where(eq(table.user.googleId, googleId));
return user || null;
}
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) { export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
if (!anonymousId || anonymousId === userId) return; if (!anonymousId || anonymousId === userId) return;
+19
View File
@@ -31,3 +31,22 @@ export async function fetchRandomVerse(): Promise<ApiVerse> {
verseText verseText
}; };
} }
/**
* Like fetchRandomVerse, but re-rolls until the picked book is not in
* `avoidBookIds` (the no-back-to-back neighbors for lazy gap-fill).
* 66 books with 2 excluded gives ~97% success per try; capped at 20 tries
* before accepting whatever was last drawn (spec: "Lazy Gap-Fill").
*/
export async function fetchRandomVerseAvoiding(
avoidBookIds: string[] = []
): Promise<ApiVerse> {
const avoid = new Set(avoidBookIds.filter(Boolean));
let last: ApiVerse | null = null;
for (let i = 0; i < 20; i++) {
const v = await fetchRandomVerse();
last = v;
if (!avoid.has(v.bookId)) return v;
}
return last as ApiVerse;
}
+30 -3
View File
@@ -1,9 +1,16 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema'; import { dailyVerses } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { fetchRandomVerse } from '$lib/server/bible-api'; import { fetchRandomVerse, fetchRandomVerseAvoiding } from '$lib/server/bible-api';
import type { DailyVerse } from '$lib/server/db/schema'; import type { DailyVerse } from '$lib/server/db/schema';
/** Add `n` days to a YYYY-MM-DD string using pure UTC arithmetic. */
function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> { export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
// Validate date format (YYYY-MM-DD) // Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
@@ -16,8 +23,28 @@ export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
return existing[0]; return existing[0];
} }
// Otherwise get a new random verse for this date // Otherwise get a new random verse for this date. Gap-fill is extended to
const apiVerse = await fetchRandomVerse(); // respect the no-back-to-back rule: the random verse's book must differ from
// any committed neighbor (D-1 / D+1). This preserves the global invariant
// that no two consecutive calendar days ever feature the same book
// (spec: "Lazy Gap-Fill").
const [prev] = await db
.select({ bookId: dailyVerses.bookId })
.from(dailyVerses)
.where(eq(dailyVerses.date, addDays(dateStr, -1)))
.limit(1);
const [next] = await db
.select({ bookId: dailyVerses.bookId })
.from(dailyVerses)
.where(eq(dailyVerses.date, addDays(dateStr, 1)))
.limit(1);
const avoid = [prev?.bookId, next?.bookId].filter((b): b is string => !!b);
const apiVerse =
avoid.length > 0
? await fetchRandomVerseAvoiding(avoid)
: await fetchRandomVerse();
const createdAt = sql`${Math.floor(Date.now() / 1000)}`; const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
const newVerse: Omit<DailyVerse, 'createdAt'> = { const newVerse: Omit<DailyVerse, 'createdAt'> = {
+18
View File
@@ -7,6 +7,7 @@ export const user = sqliteTable('user', {
email: text('email').unique(), email: text('email').unique(),
passwordHash: text('password_hash'), passwordHash: text('password_hash'),
appleId: text('apple_id').unique(), appleId: text('apple_id').unique(),
googleId: text('google_id').unique(),
isPrivate: integer('is_private', { mode: 'boolean' }).default(false) isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
}); });
@@ -47,3 +48,20 @@ export const dailyCompletions = sqliteTable('daily_completions', {
]); ]);
export type DailyCompletion = typeof dailyCompletions.$inferSelect; export type DailyCompletion = typeof dailyCompletions.$inferSelect;
export const verseSubmissions = sqliteTable('verse_submissions', {
id: text('id').primaryKey(),
// Nullable so ON DELETE SET NULL can clear attribution when a user is deleted;
// the scheduled verse still plays out (canonical text lives on daily_verses).
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
scheduledDate: text('scheduled_date').notNull().unique(),
selectedBookId: text('selected_book_id').notNull(),
selectedChapter: integer('selected_chapter').notNull(),
selectedVerse: integer('selected_verse').notNull(),
// Raw integer of server UTC milliseconds — drives the 7-day rolling cooldown.
submittedAt: integer('submitted_at').notNull(),
}, (table) => [
index('verse_submissions_user_id_idx').on(table.userId),
]);
export type VerseSubmission = typeof verseSubmissions.$inferSelect;
+65
View File
@@ -0,0 +1,65 @@
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
export function getGoogleAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: Bun.env.GOOGLE_CLIENT_ID!,
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`,
response_type: 'code',
scope: 'openid email profile',
state,
access_type: 'online',
prompt: 'select_account'
});
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
}
export async function exchangeGoogleCode(
code: string,
redirectUri: string
): Promise<{
access_token: string;
token_type: string;
expires_in: number;
id_token: string;
scope: string;
}> {
const params = new URLSearchParams({
client_id: Bun.env.GOOGLE_CLIENT_ID!,
client_secret: Bun.env.GOOGLE_CLIENT_SECRET!,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri
});
const response = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Google token exchange failed: ${errText}`);
}
return await response.json();
}
/**
* Decode Google's id_token JWT payload without signature verification.
* Safe because the token is received directly from Google's token endpoint over TLS.
*/
export function decodeGoogleIdToken(idToken: string): {
sub: string;
email?: string;
email_verified?: boolean;
name?: string;
given_name?: string;
family_name?: string;
} {
const [, payloadB64] = idToken.split('.');
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
return payload;
}
+282
View File
@@ -0,0 +1,282 @@
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { bibleBooks } from '$lib/types/bible';
import { inArray } from 'drizzle-orm';
import type { DailyCompletion } from '$lib/server/db/schema';
export type Milestone = {
id: string;
name: string;
emoji: string;
description: string;
achieved: boolean;
achievedDate: string | null; // YYYY-MM-DD of first achievement, or null
};
export type ClassicMilestoneInputs = {
bestSingleGame: { date: string; bookName: string } | null;
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
};
export async function calculateMilestones(
completions: DailyCompletion[],
dateToBookId: Map<string, string>,
classic: ClassicMilestoneInputs,
): Promise<Milestone[]> {
const sorted = [...completions].sort((a, b) => a.date.localeCompare(b.date));
// Helper: returns the date when all books in targetIds were first solved
function findSetDate(targetIds: Set<string>): string | null {
const solved = new Set<string>();
for (const c of sorted) {
const bookId = dateToBookId.get(c.date);
if (bookId && targetIds.has(bookId)) {
solved.add(bookId);
if (solved.size === targetIds.size) return c.date;
}
}
return null;
}
// Book sets
const ntIds = new Set(bibleBooks.filter(b => b.testament === 'new').map(b => b.id));
const otIds = new Set(bibleBooks.filter(b => b.testament === 'old').map(b => b.id));
const allIds = new Set(bibleBooks.map(b => b.id));
const gospelIds = new Set(['MAT', 'MRK', 'LUK', 'JHN']);
const pentateuchIds = new Set(['GEN', 'EXO', 'LEV', 'NUM', 'DEU']);
// Set-completion milestones
const ntScholarDate = findSetDate(ntIds);
const otScholarDate = findSetDate(otIds);
const theologianDate = findSetDate(allIds);
const fantasticFourDate = findSetDate(gospelIds);
const pentatonixDate = findSetDate(pentateuchIds);
// With God, All Things Are Possible — solved in 1 guess for at least one puzzle from each of the 66 books
const booksInOne = new Set<string>();
let withGodDate: string | null = null;
for (const c of sorted) {
if (c.guessCount === 1) {
const bookId = dateToBookId.get(c.date);
if (bookId) {
booksInOne.add(bookId);
if (withGodDate === null && booksInOne.size === allIds.size) {
withGodDate = c.date;
}
}
}
}
const allInOne = booksInOne.size === allIds.size;
// Is This A Joke To You? — guessed all 65 other books first (66 guesses total)
const jokeCompletion = sorted.find(c => c.guessCount >= 66);
// Prodigal Son — returned after a 30+ day gap
let prodigalDate: string | null = null;
for (let i = 1; i < sorted.length; i++) {
const prev = new Date(sorted[i - 1].date + 'T00:00:00Z');
const curr = new Date(sorted[i].date + 'T00:00:00Z');
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
if (diff >= 30) {
prodigalDate = sorted[i].date;
break;
}
}
// Extra Credit — solved on a Sunday
const sundayCompletion = sorted.find(c => {
const d = new Date(c.date + 'T00:00:00Z');
return d.getUTCDay() === 0;
});
// Cross-user milestones: Overachiever, Procrastinator, Outlier
let overachieverDate: string | null = null;
let procrastinatorDate: string | null = null;
let outlierDate: string | null = null;
if (sorted.length > 0) {
const userDates = sorted.map(c => c.date);
const allOnDates = await db
.select({
date: dailyCompletions.date,
completedAt: dailyCompletions.completedAt,
guessCount: dailyCompletions.guessCount,
anonymousId: dailyCompletions.anonymousId,
})
.from(dailyCompletions)
.where(inArray(dailyCompletions.date, userDates));
// Group all completions by date
const byDate = new Map<string, typeof allOnDates>();
for (const c of allOnDates) {
const arr = byDate.get(c.date) ?? [];
arr.push(c);
byDate.set(c.date, arr);
}
const userByDate = new Map(sorted.map(c => [c.date, c]));
for (const userComp of sorted) {
const allForDate = byDate.get(userComp.date) ?? [];
if (allForDate.length < 2) continue; // need multiple players
const validTimes = allForDate
.filter(c => c.completedAt != null)
.map(c => c.completedAt!.getTime());
if (!overachieverDate && userComp.completedAt && validTimes.length > 0) {
const earliest = Math.min(...validTimes);
if (userComp.completedAt.getTime() === earliest) {
overachieverDate = userComp.date;
}
}
if (!procrastinatorDate && userComp.completedAt && validTimes.length > 0) {
const latest = Math.max(...validTimes);
if (userComp.completedAt.getTime() === latest) {
procrastinatorDate = userComp.date;
}
}
if (!outlierDate && allForDate.length >= 10) {
const sortedGuesses = allForDate.map(c => c.guessCount).sort((a, b) => a - b);
const cutoffIndex = Math.ceil(sortedGuesses.length * 0.1) - 1;
const cutoff = sortedGuesses[cutoffIndex];
if (userComp.guessCount <= cutoff) {
outlierDate = userComp.date;
}
}
}
}
return [
{
id: 'first-1-guess',
name: 'Lightning Strike',
emoji: '⚡',
description: `First 1-guess solve${classic.bestSingleGame ? `${classic.bestSingleGame.bookName}` : ''}`,
achieved: classic.bestSingleGame !== null,
achievedDate: classic.bestSingleGame?.date ?? null,
},
{
id: 'streak-7',
name: '7-Day Streak',
emoji: '🔥',
description: 'Solve Bibdle 7 days in a row',
achieved: classic.streakMilestones.days7 !== null,
achievedDate: classic.streakMilestones.days7,
},
{
id: 'streak-14',
name: '14-Day Streak',
emoji: '💥',
description: 'Solve Bibdle 14 days in a row',
achieved: classic.streakMilestones.days14 !== null,
achievedDate: classic.streakMilestones.days14,
},
{
id: 'streak-30',
name: '30-Day Streak',
emoji: '🏅',
description: 'Solve Bibdle 30 days in a row',
achieved: classic.streakMilestones.days30 !== null,
achievedDate: classic.streakMilestones.days30,
},
{
id: 'nt-scholar',
name: 'NT Scholar',
emoji: '✝️',
description: 'Solve for every New Testament book',
achieved: ntScholarDate !== null,
achievedDate: ntScholarDate,
},
{
id: 'ot-scholar',
name: 'OT Scholar',
emoji: '📜',
description: 'Solve for every Old Testament book',
achieved: otScholarDate !== null,
achievedDate: otScholarDate,
},
{
id: 'theologian',
name: 'Theologian',
emoji: '🎓',
description: 'Solve for all 66 books of the Bible',
achieved: theologianDate !== null,
achievedDate: theologianDate,
},
{
id: 'fantastic-four',
name: 'The Fantastic Four',
emoji: '4️⃣',
description: 'Solve a puzzle for all four Gospels',
achieved: fantasticFourDate !== null,
achievedDate: fantasticFourDate,
},
{
id: 'pentatonix',
name: 'Pentatonix',
emoji: '📃',
description: 'Solve a puzzle for all five books of the Pentateuch',
achieved: pentatonixDate !== null,
achievedDate: pentatonixDate,
},
{
id: 'with-god',
name: 'With God, All Things Are Possible',
emoji: '🙏',
description: 'Solve in 1 guess for each of the 66 books at least once',
achieved: allInOne,
achievedDate: withGodDate,
},
{
id: 'is-this-a-joke',
name: 'Is This A Joke To You?',
emoji: '😤',
description: 'Guess all 65 other books before getting the right one',
achieved: jokeCompletion !== undefined,
achievedDate: jokeCompletion?.date ?? null,
},
{
id: 'overachiever',
name: 'Overachiever',
emoji: '⚡',
description: 'Be the first person to solve Bibdle on a day',
achieved: overachieverDate !== null,
achievedDate: overachieverDate,
},
{
id: 'procrastinator',
name: 'Procrastinator',
emoji: '🐢',
description: 'Be the last person to solve Bibdle on a day',
achieved: procrastinatorDate !== null,
achievedDate: procrastinatorDate,
},
{
id: 'prodigal-son',
name: 'Prodigal Son',
emoji: '🏠',
description: 'Return to Bibdle after at least 30 days away',
achieved: prodigalDate !== null,
achievedDate: prodigalDate,
},
{
id: 'extra-credit',
name: 'Extra Credit',
emoji: '📅',
description: 'Solve Bibdle on a Sunday',
achieved: sundayCompletion !== undefined,
achievedDate: sundayCompletion?.date ?? null,
},
{
id: 'outlier',
name: 'Outlier',
emoji: '📊',
description: 'Finish in the top 10% of solves on a day (fewest guesses)',
achieved: outlierDate !== null,
achievedDate: outlierDate,
},
];
}
+293
View File
@@ -0,0 +1,293 @@
import { db as defaultDb } from '$lib/server/db';
import { dailyVerses, verseSubmissions } from '$lib/server/db/schema';
import { eq, desc, sql } from 'drizzle-orm';
import { getBookById } from '$lib/server/bible';
// Drizzle instance type alias (the production db or the test db).
export type Db = typeof defaultDb;
import {
composeVerseWindow,
formatWindowReference,
getChapterCount,
getVerseCount
} from '$lib/server/xml-bible';
// Server-side constants for the community-verse-submission feature
// (spec: "Rate Limiting & Cooldown", "Scheduling Algorithm").
const DAY_MS = 1000 * 60 * 60 * 24;
export const COOLDOWN_MS = 7 * DAY_MS;
export const REPEAT_WINDOW_DAYS = 60;
const MAX_SCHEDULE_RETRIES = 5;
// Safety cap on the day-by-day forward scan. 10k days (~27 years) is well
// beyond any realistic calendar density; prevents an accidental infinite loop.
const MAX_SCAN_DAYS = 10_000;
export interface SubmissionInput {
bookId: string;
chapter: number;
verse: number;
}
export interface ScheduleResult {
scheduledDate: string;
reference: string;
windowText: string;
bookId: string;
}
/** Add `n` days to a YYYY-MM-DD string using pure UTC arithmetic. */
export function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
/** Whole-day difference (b - a) between two YYYY-MM-DD strings, UTC. */
export function dayDiff(a: string, b: string): number {
const ta = new Date(a + 'T00:00:00Z').getTime();
const tb = new Date(b + 'T00:00:00Z').getTime();
return Math.round((tb - ta) / DAY_MS);
}
/** Current server UTC date as YYYY-MM-DD. */
export function todayUtcStr(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* The canonical identity of a 3-verse window, used by the 60-day repeat rule.
* Combines bookId + formatted reference so identical windows collide regardless
* of how the underlying row was produced (submission vs. random gap-fill).
*/
function windowRef(bookId: string, reference: string): string {
return `${bookId}|${reference}`;
}
/** Is this a SQLite unique-constraint violation (used for retry-on-conflict)? */
export function isUniqueConstraintError(err: unknown): boolean {
const e = err as { code?: string; message?: string } | null;
return !!e && (
e.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
e.code === 'SQLITE_CONSTRAINT' ||
!!(e.message && /UNIQUE/i.test(e.message))
);
}
export interface CooldownState {
/** True if the user submitted within the last 7 days. */
onCooldown: boolean;
/** Server UTC millis when the cooldown expires, or null. */
cooldownEndsAt: number | null;
}
/** Compute the rolling 7-day cooldown state for a user (server UTC). */
export async function getCooldownState(
db: Db,
userId: string,
now: number = Date.now()
): Promise<CooldownState> {
const [last] = await db
.select({ submittedAt: verseSubmissions.submittedAt })
.from(verseSubmissions)
.where(eq(verseSubmissions.userId, userId))
.orderBy(desc(verseSubmissions.submittedAt))
.limit(1);
if (!last) {
return { onCooldown: false, cooldownEndsAt: null };
}
const cooldownEndsAt = last.submittedAt + COOLDOWN_MS;
if (now < cooldownEndsAt) {
return { onCooldown: true, cooldownEndsAt };
}
return { onCooldown: false, cooldownEndsAt: null };
}
/** Structural validation of a user-supplied (bookId, chapter, verse) selection. */
export function validateSelection(input: SubmissionInput): string | null {
const book = getBookById(input.bookId);
if (!book) return 'Unknown bookId';
if (!Number.isInteger(input.chapter) || input.chapter < 1) {
return 'chapter must be a positive integer';
}
const chapterCount = getChapterCount(book.order);
if (input.chapter > chapterCount) {
return `chapter out of range (1-${chapterCount})`;
}
if (!Number.isInteger(input.verse) || input.verse < 1) {
return 'verse must be a positive integer';
}
const verseCount = getVerseCount(book.order, input.chapter);
if (input.verse > verseCount) {
return `verse out of range (1-${verseCount})`;
}
return null;
}
interface LoadedCalendar {
byDate: Map<string, { bookId: string; windowRef: string }>;
datesByWindowRef: Map<string, string[]>;
}
/** Load every committed daily_verses row into in-memory indexes for the scan. */
async function loadCalendar(db: Db): Promise<LoadedCalendar> {
const rows = await db
.select({
date: dailyVerses.date,
bookId: dailyVerses.bookId,
reference: dailyVerses.reference
})
.from(dailyVerses);
const byDate = new Map<string, { bookId: string; windowRef: string }>();
const datesByWindowRef = new Map<string, string[]>();
for (const r of rows) {
const wr = windowRef(r.bookId, r.reference);
byDate.set(r.date, { bookId: r.bookId, windowRef: wr });
const arr = datesByWindowRef.get(wr);
if (arr) arr.push(r.date);
else datesByWindowRef.set(wr, [r.date]);
}
return { byDate, datesByWindowRef };
}
/**
* Find the earliest valid candidate date `D` (scanning forward from tomorrow,
* server UTC) for a submission with the given book + window identity.
*
* Validity (spec "Scheduling Algorithm"):
* 1. Empty: no daily_verses row at D.
* 2. No back-to-back: D-1 (if committed) has a different book.
* 3. No back-to-back: D+1 (if committed) has a different book.
* 4. 60-day repeat: no identical window within [D-60, D+60] inclusive.
*/
async function findCandidateDate(
db: Db,
bookId: string,
submissionWindowRef: string,
opts?: { startFrom?: string; today?: string }
): Promise<string | null> {
const { byDate, datesByWindowRef } = await loadCalendar(db);
let cursor = opts?.startFrom ?? addDays(opts?.today ?? todayUtcStr(), 1);
for (let i = 0; i < MAX_SCAN_DAYS; i++) {
// Rule 1: must be empty.
if (!byDate.has(cursor)) {
// Rules 2 & 3: no back-to-back with committed neighbors.
const prev = byDate.get(addDays(cursor, -1));
const next = byDate.get(addDays(cursor, 1));
const backToBack =
(!!prev && prev.bookId === bookId) ||
(!!next && next.bookId === bookId);
if (!backToBack) {
// Rule 4: 60-day repeat distance for the identical window.
const sameWindowDates = datesByWindowRef.get(submissionWindowRef) ?? [];
const tooClose = sameWindowDates.some(
(d) => Math.abs(dayDiff(d, cursor)) <= REPEAT_WINDOW_DAYS
);
if (!tooClose) {
return cursor;
}
}
}
cursor = addDays(cursor, 1);
}
return null;
}
/**
* Run the scheduling scan and write the daily_verses + verse_submissions rows
* in a transaction. On a unique-constraint conflict (concurrent submission
* picked the same date), re-run the scan from the next day; retry up to a
* small bound.
*/
export async function scheduleSubmission(
db: Db,
input: SubmissionInput,
userId: string,
now: number = Date.now(),
opts?: { today?: string }
): Promise<ScheduleResult> {
const book = getBookById(input.bookId);
if (!book) throw new Error('Invalid bookId');
const window = composeVerseWindow(book.order, input.chapter, input.verse);
if (!window) throw new Error('Invalid chapter/verse for this book');
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
const windowText = window.verses.join(' ');
const submissionWindowRef = windowRef(book.id, reference);
let lastCandidate = '';
for (let attempt = 0; attempt < MAX_SCHEDULE_RETRIES; attempt++) {
// Re-compute the candidate each attempt — a concurrent submission may
// have claimed the previous candidate between scan and insert. After a
// conflict, re-scan from the day *after* the contested candidate.
const candidate = await findCandidateDate(
db,
book.id,
submissionWindowRef,
attempt === 0
? { today: opts?.today }
: { startFrom: addDays(lastCandidate, 1) }
);
if (!candidate) {
throw new Error('No valid candidate date found within the scan window');
}
lastCandidate = candidate;
try {
db.transaction((tx) => {
tx.insert(dailyVerses)
.values({
id: Bun.randomUUIDv7(),
date: candidate,
bookId: book.id,
verseText: windowText,
reference,
createdAt: sql`${Math.floor(now / 1000)}`
})
.run();
tx.insert(verseSubmissions)
.values({
id: Bun.randomUUIDv7(),
userId,
scheduledDate: candidate,
selectedBookId: input.bookId,
selectedChapter: input.chapter,
selectedVerse: input.verse,
submittedAt: now
})
.run();
});
return {
scheduledDate: candidate,
reference,
windowText,
bookId: book.id
};
} catch (err) {
if (isUniqueConstraintError(err)) {
continue; // retry — re-scan from tomorrow
}
throw err;
}
}
throw new Error('Failed to schedule submission after retries');
}
+126 -2
View File
@@ -135,7 +135,7 @@ function getGreekChapter(bookNumber: number, chapterNumber: number): ChapterData
/** /**
* Get the number of verses in a specific chapter * Get the number of verses in a specific chapter
*/ */
function getVerseCount(bookNumber: number, chapterNumber: number): number { export function getVerseCount(bookNumber: number, chapterNumber: number): number {
const chapter = getChapter(bookNumber, chapterNumber); const chapter = getChapter(bookNumber, chapterNumber);
return chapter ? chapter.verse.length : 0; return chapter ? chapter.verse.length : 0;
} }
@@ -151,7 +151,7 @@ function getGreekVerseCount(bookNumber: number, chapterNumber: number): number {
/** /**
* Get the number of chapters in a specific book * Get the number of chapters in a specific book
*/ */
function getChapterCount(bookNumber: number): number { export function getChapterCount(bookNumber: number): number {
const book = getBook(bookNumber); const book = getBook(bookNumber);
return book ? book.chapter.length : 0; return book ? book.chapter.length : 0;
} }
@@ -439,3 +439,127 @@ export function getAllNKJVVerses(): Array<{ text: string; book: string; chapter:
} }
return verses; return verses;
} }
/**
* Resolve a 1-based book-wide verse index to { chapter, verse }.
* The index is contiguous across all chapters of the book.
*/
function resolveBookVerseIndex(
bookNumber: number,
index: number
): { chapter: number; verse: number } | null {
const chapterCount = getChapterCount(bookNumber);
let cumulative = 0;
for (let c = 1; c <= chapterCount; c++) {
const vCount = getVerseCount(bookNumber, c);
if (index <= cumulative + vCount) {
return { chapter: c, verse: index - cumulative };
}
cumulative += vCount;
}
return null;
}
export interface ComposedWindow {
bookId: string;
bookName: string;
verses: string[]; // the 3 verse texts in order
startChapter: number;
startVerse: number;
endChapter: number;
endVerse: number;
selectedVerse: number; // the anchor verse number within its chapter
}
/**
* Compose a 3-verse window anchored on (book, chapter, verse), constrained to
* a single book. Default window is [v-2, v-1, v] in book-wide verse indices;
* if that would run before the book's start (v is 1 or 2), fall forward to
* [v, v+1, v+2]. Cross-chapter windows within the same book are allowed.
*
* Mirrors the spec's "Verse Windowing" section.
*/
export function composeVerseWindow(
bookNumber: number,
chapter: number,
verse: number
): ComposedWindow | null {
const book = getBookByNumber(bookNumber);
if (!book) return null;
const chapterCount = getChapterCount(bookNumber);
if (chapter < 1 || chapter > chapterCount) return null;
const verseCount = getVerseCount(bookNumber, chapter);
if (verse < 1 || verse > verseCount) return null;
// Book-wide 1-based index of the selected verse.
let selectedIndex = 0;
for (let c = 1; c < chapter; c++) selectedIndex += getVerseCount(bookNumber, c);
selectedIndex += verse;
let startIdx: number;
let endIdx: number;
if (selectedIndex - 2 < 1) {
// Fall forward: [v, v+1, v+2]
startIdx = selectedIndex;
endIdx = selectedIndex + 2;
} else {
startIdx = selectedIndex - 2;
endIdx = selectedIndex;
}
const start = resolveBookVerseIndex(bookNumber, startIdx);
const end = resolveBookVerseIndex(bookNumber, endIdx);
if (!start || !end) return null;
// Extract the contiguous window, crossing chapter boundaries within the book as needed.
const verses: string[] = [];
let curChapter = start.chapter;
let curVerse = start.verse;
while (verses.length < 3 && curChapter <= chapterCount) {
const vCount = getVerseCount(bookNumber, curChapter);
while (curVerse <= vCount && verses.length < 3) {
const ex = extractVerses(bookNumber, curChapter, curVerse, 1);
if (ex.length === 1) {
verses.push(ex[0]);
} else {
break;
}
curVerse++;
}
curChapter++;
curVerse = 1;
}
if (verses.length < 3) return null;
return {
bookId: book.id,
bookName: book.name,
verses,
startChapter: start.chapter,
startVerse: start.verse,
endChapter: end.chapter,
endVerse: end.verse,
selectedVerse: verse
};
}
/**
* Format a reference for a window that may span chapters within a book.
* Same-chapter: "Genesis 1:1-3" (hyphen)
* Cross-chapter: "Genesis 1:312:1" (en-dash)
*/
export function formatWindowReference(
bookName: string,
startChapter: number,
startVerse: number,
endChapter: number,
endVerse: number
): string {
if (startChapter === endChapter) {
if (startVerse === endVerse) return `${bookName} ${startChapter}:${startVerse}`;
return `${bookName} ${startChapter}:${startVerse}-${endVerse}`;
}
return `${bookName} ${startChapter}:${startVerse}\u2013${endChapter}:${endVerse}`;
}
+1 -9
View File
@@ -53,15 +53,7 @@ export function createGamePersistence(
if ((window as any).umami) { if ((window as any).umami) {
(window as any).umami.identify(anonymousId); (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 date = getDate();
const reference = getReference(); const reference = getReference();
+31 -8
View File
@@ -1,17 +1,28 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { browser } from "$app/environment";
import "./layout.css"; import "./layout.css";
import favicon from "$lib/assets/favicon.ico"; import favicon from "$lib/assets/favicon.ico";
import TitleAnimation from "$lib/components/TitleAnimation.svelte"; import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
let isDev = $state(false);
onMount(() => { onMount(() => {
isDev =
window.location.host === "localhost:5173" ||
window.location.host === "test.bibdle.com";
// Inject analytics script // Inject analytics script
const script = document.createElement('script'); const script = document.createElement("script");
script.defer = true; script.defer = true;
script.src = 'https://umami.snail.city/script.js'; script.src = "https://umami.snail.city/script.js";
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc'); script.setAttribute(
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com'); "data-website-id",
"5b8c31ad-71cd-4317-940b-6bccea732acc",
);
script.setAttribute("data-domains", "bibdle.com,www.bibdle.com");
document.body.appendChild(script); document.body.appendChild(script);
}); });
@@ -20,16 +31,28 @@
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" /> <link
<meta name="description" content="A daily Bible game" /> rel="alternate"
type="application/rss+xml"
title="Bibdle RSS Feed"
href="/feed.xml"
/>
<meta name="description" content="A Wordle-inspired daily Bible game" />
</svelte:head> </svelte:head>
<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"> <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 <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" 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 /> <TitleAnimation />
<div class="font-normal"></div> <div class="font-normal"></div>
</h1> </h1>
{#if isDev}
<div class="flex justify-center pb-2"><ThemeToggle /></div>
{:else}
<div class="justify-center hidden pb-2"><ThemeToggle /></div>
{/if}
{@render children()} {@render children()}
</div> </div>
+131 -14
View File
@@ -2,17 +2,19 @@
import type { PageProps } from "./$types"; import type { PageProps } from "./$types";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import { onMount } from "svelte";
import VerseDisplay from "$lib/components/VerseDisplay.svelte"; import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte"; import SearchInput from "$lib/components/SearchInput.svelte";
import GuessesTable from "$lib/components/GuessesTable.svelte"; import GuessesTable from "$lib/components/GuessesTable.svelte";
import WinScreen from "$lib/components/WinScreen.svelte"; import WinScreen from "$lib/components/WinScreen.svelte";
import Credits from "$lib/components/Credits.svelte"; import Credits from "$lib/components/Credits.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import GamePrompt from "$lib/components/GamePrompt.svelte";
import DevButtons from "$lib/components/DevButtons.svelte"; import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte"; import AuthModal from "$lib/components/AuthModal.svelte";
import { evaluateGuess } from "$lib/utils/game"; import { evaluateGuess, getFirstLetter } from "$lib/utils/game";
import { import {
generateShareText, generateShareText,
shareResult, shareResult,
@@ -52,6 +54,7 @@
let statsData = $state<StatsData | null>(null); let statsData = $state<StatsData | null>(null);
let streak = $state(0); let streak = $state(0);
let streakPercentile = $state<number | null>(null); let streakPercentile = $state<number | null>(null);
let guessesMinimized = $state(false);
const persistence = createGamePersistence( const persistence = createGamePersistence(
() => dailyVerse.date, () => dailyVerse.date,
@@ -73,6 +76,62 @@
!persistence.chapterGuessCompleted, !persistence.chapterGuessCompleted,
); );
let knownTestament = $derived(
persistence.guesses.some((g) => g.testamentMatch)
? correctBook?.testament
: null,
);
let knownSection = $derived(
persistence.guesses.some((g) => g.sectionMatch)
? correctBook?.section
: null,
);
let knownFirstLetter = $derived(
persistence.guesses.some((g) => g.firstLetterMatch)
? getFirstLetter(correctBook?.name ?? "").toUpperCase()
: null,
);
let testamentVisible = $state(false);
let sectionVisible = $state(false);
let firstLetterVisible = $state(false);
let showHints = $state(false);
// On page load, show hints that are already known without animation
onMount(() => {
if (knownTestament) testamentVisible = true;
if (knownSection) sectionVisible = true;
if (knownFirstLetter) firstLetterVisible = true;
const winCount = Object.keys(localStorage).filter(
(k) => k.startsWith("bibdle-win-tracked-") && localStorage.getItem(k) === "true"
).length;
showHints = winCount < 3;
});
// Fade in newly revealed hints after the guess animation completes
$effect(() => {
if (!knownTestament || testamentVisible) return;
const id = setTimeout(() => {
testamentVisible = true;
}, 2800);
return () => clearTimeout(id);
});
$effect(() => {
if (!knownSection || sectionVisible) return;
const id = setTimeout(() => {
sectionVisible = true;
}, 2800);
return () => clearTimeout(id);
});
$effect(() => {
if (!knownFirstLetter || firstLetterVisible) return;
const id = setTimeout(() => {
firstLetterVisible = true;
}, 2800);
return () => clearTimeout(id);
});
async function submitGuess(bookId: string) { async function submitGuess(bookId: string) {
if (persistence.guesses.some((g) => g.book.id === bookId)) return; if (persistence.guesses.some((g) => g.book.id === bookId)) return;
@@ -87,7 +146,6 @@
(window as any).umami (window as any).umami
) { ) {
(window as any).umami.track("First guess"); (window as any).umami.track("First guess");
(window as any).rybbit?.event("First guess");
localStorage.setItem(key, "true"); localStorage.setItem(key, "true");
} }
} }
@@ -203,6 +261,23 @@
} }
}); });
// Delay collapsing the guesses grid until animations complete (mirrors showWinScreen delay)
$effect(() => {
if (!isWon || persistence.guesses.length <= 3) {
guessesMinimized = false;
return;
}
if (persistence.isWinAlreadyTracked()) {
guessesMinimized = true;
} else {
const animationDelay = 1800;
const timeoutId = setTimeout(() => {
guessesMinimized = true;
}, animationDelay);
return () => clearTimeout(timeoutId);
}
});
// Track win analytics // Track win analytics
$effect(() => { $effect(() => {
if (!browser || !isWon) return; if (!browser || !isWon) return;
@@ -211,9 +286,6 @@
(window as any).umami.track("Guessed correctly", { (window as any).umami.track("Guessed correctly", {
totalGuesses: persistence.guesses.length, totalGuesses: persistence.guesses.length,
}); });
(window as any).rybbit?.event("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
} }
}); });
@@ -297,6 +369,44 @@
{#if !isWon} {#if !isWon}
<div class="animate-fade-in-up animate-delay-400"> <div class="animate-fade-in-up animate-delay-400">
<GamePrompt guessCount={persistence.guesses.length} />
{#if showHints && (knownTestament || knownSection || knownFirstLetter)}
<div
class="text-xs uppercase tracking-widest font-bold text-center text-gray-500 dark:text-gray-400 flex flex-col gap-1 mb-4 -mt-2"
>
{#if knownTestament}
<p
style="transition: opacity 0.5s ease; opacity: {testamentVisible
? 1
: 0};"
>
It is in the {knownTestament === "old"
? "Old"
: "New"} Testament.
</p>
{/if}
{#if knownSection}
<p
style="transition: opacity 0.5s ease; opacity: {sectionVisible
? 1
: 0};"
>
It is in the {knownSection} section.
</p>
{/if}
{#if knownFirstLetter}
<p
style="transition: opacity 0.5s ease; opacity: {firstLetterVisible
? 1
: 0};"
>
The book's name starts with "{knownFirstLetter}".
</p>
{/if}
</div>
{/if}
<SearchInput <SearchInput
bind:searchQuery bind:searchQuery
{guessedIds} {guessedIds}
@@ -322,24 +432,27 @@
{streakPercentile} {streakPercentile}
isLoggedIn={!!user} isLoggedIn={!!user}
anonymousId={persistence.anonymousId} anonymousId={persistence.anonymousId}
localDate={new Date().toLocaleDateString("en-CA")}
/> />
</div> </div>
{/if} {/if}
<div class="animate-fade-in-up animate-delay-600"> <div class="animate-fade-in-up animate-delay-600">
<GuessesTable guesses={persistence.guesses} {correctBookId} /> <GuessesTable
guesses={persistence.guesses}
{correctBookId}
minimized={guessesMinimized}
/>
</div> </div>
{#if isWon} {#if isWon}
<hr
class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600"
/>
<div class="animate-fade-in-up animate-delay-800"> <div class="animate-fade-in-up animate-delay-800">
<Credits /> <Credits />
</div> </div>
{/if} {/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> </div>
{#if isDev} {#if isDev}
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3"> <div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
@@ -376,7 +489,11 @@
<div>Daily Verse Date: {dailyVerse.date}</div> <div>Daily Verse Date: {dailyVerse.date}</div>
<div>Streak: {streak}</div> <div>Streak: {streak}</div>
</div> </div>
<DevButtons anonymousId={persistence.anonymousId} {user} onSignIn={() => (authModalOpen = true)} /> <DevButtons
anonymousId={persistence.anonymousId}
{user}
onSignIn={() => (authModalOpen = true)}
/>
</div> </div>
{/if} {/if}
@@ -388,7 +505,7 @@
.filter(Boolean) .filter(Boolean)
.join(" ")}{user.email .join(" ")}{user.email
? ` (${user.email})` ? ` (${user.email})`
: ""}{user.appleId ? " using Apple" : ""} | : ""}{user.appleId ? " using Apple" : user.googleId ? " using Google" : ""} |
<form <form
method="POST" method="POST"
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { bibleBooks } from '$lib/server/bible';
import { getChapterCount, getVerseCount } from '$lib/server/xml-bible';
// Static Bible structure for the cascading Book → Chapter → Verse selectors.
// ~1189 chapter entries total; safe to cache indefinitely.
let cached: { bookId: string; chapters: number[] }[] | null = null;
function build(): { bookId: string; chapters: number[] }[] {
if (cached) return cached;
const out = bibleBooks.map((book) => {
const chapterCount = getChapterCount(book.order);
const chapters: number[] = new Array(chapterCount);
for (let c = 1; c <= chapterCount; c++) {
chapters[c - 1] = getVerseCount(book.order, c);
}
return { bookId: book.id, chapters };
});
cached = out;
return out;
}
export const GET: RequestHandler = async () => {
return json(build());
};
@@ -0,0 +1,189 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses, verseSubmissions, user } from '$lib/server/db/schema';
import { sql } from 'drizzle-orm';
import { json } from '@sveltejs/kit';
import { bibleBooks } from '$lib/types/bible';
import {
getChapterCount,
getVerseCount,
composeVerseWindow,
formatWindowReference
} from '$lib/server/xml-bible';
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
const MAX_SCAN_RETRIES = 5;
// Dev-only: insert a verse_submissions row (from a random existing account) +
// its corresponding daily_verses row, scheduled on the earliest valid future
// date per the spec's scheduling rules. Lets you populate /scheduled-verses to
// verify the admin view without going through the (Step 5+) submit API.
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 });
}
// Pick a random existing user.
const users = await db.select().from(user);
if (users.length === 0) {
return json(
{ error: 'No users exist yet. Sign up at least one account first.' },
{ status: 400 }
);
}
const submitter = users[Math.floor(Math.random() * users.length)];
// Pick a random valid (book, chapter, verse).
const book = bibleBooks[Math.floor(Math.random() * bibleBooks.length)];
const chapterCount = getChapterCount(book.order);
const chapter = Math.floor(Math.random() * chapterCount) + 1;
const verseCount = getVerseCount(book.order, chapter);
const verse = Math.floor(Math.random() * verseCount) + 1;
const window = composeVerseWindow(book.order, chapter, verse);
if (!window) {
return json({ error: 'Failed to compose verse window' }, { status: 500 });
}
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
const verseText = window.verses.join(' ');
const todayUtc = new Date().toISOString().slice(0, 10);
const tomorrowMs = new Date(todayUtc + 'T00:00:00Z').getTime() + 86400000;
function addDays(ms: number, n: number): string {
return new Date(ms + n * 86400000).toISOString().slice(0, 10);
}
// Fetch all future daily_verses into memory (date → bookId, date → reference).
const future = await db.select().from(dailyVerses).where(sql`date > ${todayUtc}`);
const byDate = new Map<string, { bookId: string; reference: string }>();
for (const r of future) byDate.set(r.date, { bookId: r.bookId, reference: r.reference });
// For the 60-day repeat rule, also need a bounded historical lookup per
// candidate date. Build a set of all existing references globally to do a
// cheap pre-check, then a precise range query when we settle on a date.
const allRows = await db.select().from(dailyVerses);
const refDates = new Map<string, string[]>(); // reference → list of dates
for (const r of allRows) {
const arr = refDates.get(r.reference) ?? [];
arr.push(r.date);
refDates.set(r.reference, arr);
}
let scheduledDate: string | null = null;
for (let attempt = 0; attempt < MAX_SCAN_RETRIES && !scheduledDate; attempt++) {
// Walk day-by-day from tomorrow.
for (let offset = 0; offset < 10000; offset++) {
const D = addDays(tomorrowMs, offset);
// Rule 1: empty (no committed daily_verses row).
if (byDate.has(D)) continue;
// Rule 2: no back-to-back with prior neighbor.
const prev = byDate.get(addDays(tomorrowMs, offset - 1));
if (prev && prev.bookId === book.id) continue;
// Rule 3: no back-to-back with next committed neighbor.
const next = byDate.get(addDays(tomorrowMs, offset + 1));
if (next && next.bookId === book.id) continue;
// Rule 4: 60-day repeat distance — no same reference within ±60 days.
const sameRefDates = refDates.get(reference) ?? [];
const dMs = new Date(D + 'T00:00:00Z').getTime();
const tooClose = sameRefDates.some((d) => {
const diff = Math.abs(new Date(d + 'T00:00:00Z').getTime() - dMs);
return diff <= 60 * 86400000;
});
if (tooClose) continue;
scheduledDate = D;
break;
}
if (!scheduledDate) break; // exhausted retries
}
if (!scheduledDate) {
return json({ error: 'Could not find a valid future date' }, { status: 500 });
}
// Insert both rows in a transaction. On a unique conflict (concurrent
// submission grabbed the same date), bubble up so the caller can retry.
const now = Date.now();
const verseId = Bun.randomUUIDv7();
const submissionId = Bun.randomUUIDv7();
try {
db.transaction((tx) => {
tx.insert(dailyVerses)
.values({
id: verseId,
date: scheduledDate!,
bookId: book.id,
verseText,
reference,
createdAt: sql`${Math.floor(now / 1000)}`
})
.run();
tx.insert(verseSubmissions)
.values({
id: submissionId,
userId: submitter.id,
scheduledDate: scheduledDate!,
selectedBookId: book.id,
selectedChapter: chapter,
selectedVerse: verse,
submittedAt: now
})
.run();
});
} catch (err: any) {
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
return json(
{ error: 'Date collision during seed; retry the request', detail: String(err?.message ?? err) },
{ status: 409 }
);
}
throw err;
}
return json({
ok: true,
submitter: { id: submitter.id, email: submitter.email },
selected: {
bookId: book.id,
bookName: book.name,
chapter,
verse
},
scheduledDate,
reference,
verseText
});
};
// Convenience: GET returns the current count of submissions + future scheduled
// verses, useful as a quick dev sanity check without mutating state.
export const GET: RequestHandler = async ({ request }) => {
const host = request.headers.get('host') ?? '';
if (!DEV_HOSTS.includes(host)) {
return json({ error: 'Not allowed in production' }, { status: 403 });
}
const subs = await db.select().from(verseSubmissions);
const future = await db
.select()
.from(dailyVerses)
.where(sql`date > ${new Date().toISOString().slice(0, 10)}`);
return json({
totalSubmissions: subs.length,
futureScheduledVerses: future.length
});
};
@@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVerseForDate } from '$lib/server/daily-verse';
export const POST: RequestHandler = async ({ request }) => {
const cronSecret = Bun.env.CRON_SECRET;
const discordWebhook = Bun.env.DISCORD_DAILY_WEBHOOK;
const authHeader = request.headers.get('Authorization');
if (!authHeader || !cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
const verse = await getVerseForDate(dateStr);
const fullDate = new Date(dateStr + 'T00:00:00Z').toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
const message = `*${fullDate}*\n**"${verse.verseText}"**`;
if (!discordWebhook) {
return json({ error: 'Discord webhook not configured' }, { status: 500 });
}
const discordResponse = await fetch(discordWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message }),
});
if (!discordResponse.ok) {
return json({ error: 'Failed to post to Discord' }, { status: 500 });
}
return json({ ok: true });
};
+89
View File
@@ -0,0 +1,89 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import {
scheduleSubmission,
validateSelection,
getCooldownState
} from '$lib/server/verse-submission';
/**
* POST /api/submit-verse
*
* Accepts a user-chosen verse (bookId/chapter/verse) and reserves a concrete
* future date for it via the scheduling scan. Auth required, solved-today gate,
* 7-day rolling cooldown, and structural validation all enforced here.
*
* Body: { bookId, chapter, verse, localDate }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
// 1. Auth.
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = locals.user.id;
let body: any;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { bookId, chapter, verse, localDate } = body ?? {};
// 2. Solved-today gate (engagement gate, not security).
if (typeof localDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(localDate)) {
return json({ error: 'A valid localDate (YYYY-MM-DD) is required' }, { status: 400 });
}
const [completion] = await db
.select({ id: dailyCompletions.id })
.from(dailyCompletions)
.where(
and(
eq(dailyCompletions.anonymousId, userId),
eq(dailyCompletions.date, localDate)
)
)
.limit(1);
if (!completion) {
return json({ error: "Solve today's puzzle first" }, { status: 403 });
}
// 3. Cooldown (rolling 7×24h, server UTC).
const cooldown = await getCooldownState(db, userId);
if (cooldown.onCooldown) {
return json(
{ error: 'Cooldown active', cooldownEndsAt: cooldown.cooldownEndsAt },
{ status: 429 }
);
}
// 4. Structural validation.
const validationError = validateSelection({ bookId, chapter, verse });
if (validationError) {
return json({ error: validationError }, { status: 400 });
}
// 57. Compute window + scheduling scan + transactional insert (with retry).
try {
const result = await scheduleSubmission(
db,
{ bookId, chapter, verse },
userId
);
return json(
{
scheduledDate: result.scheduledDate,
reference: result.reference,
windowText: result.windowText
},
{ status: 201 }
);
} catch (err) {
console.error('submit-verse failed:', err);
return json({ error: 'Failed to schedule submission' }, { status: 500 });
}
};
@@ -0,0 +1,69 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { verseSubmissions, dailyVerses } from '$lib/server/db/schema';
import { eq, desc, asc } from 'drizzle-orm';
import { getCooldownState, todayUtcStr } from '$lib/server/verse-submission';
/**
* GET /api/submit-verse/status?localDate=YYYY-MM-DD
*
* Returns the state needed to render the win-screen submit button:
* whether the user can submit, the active cooldown (if any), their most
* recent submission, and their not-yet-reached upcoming submissions.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = locals.user.id;
const cooldown = await getCooldownState(db, userId);
// Most recent submission (by submitted_at) — joined to daily_verses for the
// canonical reference/window text that will actually play on its day.
const [lastRow] = await db
.select({
scheduledDate: verseSubmissions.scheduledDate,
reference: dailyVerses.reference
})
.from(verseSubmissions)
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
.where(eq(verseSubmissions.userId, userId))
.orderBy(desc(verseSubmissions.submittedAt))
.limit(1);
const lastSubmission = lastRow
? {
scheduledDate: lastRow.scheduledDate,
reference: lastRow.reference
}
: null;
// Upcoming = this user's submissions whose scheduled date hasn't been
// reached yet (server UTC today).
const today = todayUtcStr();
const upcomingRows = await db
.select({
scheduledDate: verseSubmissions.scheduledDate,
reference: dailyVerses.reference
})
.from(verseSubmissions)
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
.where(eq(verseSubmissions.userId, userId))
.orderBy(asc(verseSubmissions.scheduledDate));
const upcoming = upcomingRows
.filter((r) => r.scheduledDate > today)
.map((r) => ({
scheduledDate: r.scheduledDate,
reference: r.reference
}));
return json({
canSubmit: !cooldown.onCooldown,
cooldownEndsAt: cooldown.cooldownEndsAt,
lastSubmission,
upcoming
});
};
+51
View File
@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBookById } from '$lib/server/bible';
import {
composeVerseWindow,
formatWindowReference
} from '$lib/server/xml-bible';
// Live 3-verse preview for the submission selector. Computes the fall-forward
// window anchored on the selected verse (spec: "Verse Windowing").
export const GET: RequestHandler = async ({ url }) => {
const bookId = url.searchParams.get('bookId');
const chapterParam = url.searchParams.get('chapter');
const verseParam = url.searchParams.get('verse');
if (!bookId || !chapterParam || !verseParam) {
return json({ error: 'bookId, chapter, and verse are required' }, { status: 400 });
}
const book = getBookById(bookId);
if (!book) {
return json({ error: 'Unknown bookId' }, { status: 400 });
}
const chapter = Number(chapterParam);
const verse = Number(verseParam);
if (!Number.isInteger(chapter) || !Number.isInteger(verse) || chapter < 1 || verse < 1) {
return json({ error: 'chapter and verse must be positive integers' }, { status: 400 });
}
const window = composeVerseWindow(book.order, chapter, verse);
if (!window) {
// chapter/verse out of range — caught by composeVerseWindow's bounds checks
return json({ error: 'chapter or verse out of range for this book' }, { status: 400 });
}
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
return json({
windowVerses: window.verses,
reference,
bookId: window.bookId,
selectedVerse: window.selectedVerse
});
};
+1 -1
View File
@@ -131,7 +131,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Create session // Create session
const sessionToken = auth.generateSessionToken(); const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId); const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt); auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
redirect(302, '/'); redirect(302, '/');
}; };
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { getGoogleAuthUrl } from '$lib/server/google-auth';
export const actions: Actions = {
default: async ({ cookies, request }) => {
const data = await request.formData();
const anonymousId = data.get('anonymousId')?.toString() || '';
// Generate CSRF state
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
const state = Buffer.from(stateBytes).toString('base64url');
// sameSite 'lax' is safe here because Google sends a GET redirect back
// (unlike Apple which POSTs cross-origin, requiring 'none')
cookies.set('google_oauth_state', JSON.stringify({ state, anonymousId }), {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 600
});
redirect(302, getGoogleAuthUrl(state));
}
};
+131
View File
@@ -0,0 +1,131 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { exchangeGoogleCode, decodeGoogleIdToken } from '$lib/server/google-auth';
import * as auth from '$lib/server/auth';
import { db } from '$lib/server/db';
import { user as userTable } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url, cookies }) => {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
// User denied access
if (errorParam) {
redirect(302, '/');
}
const storedRaw = cookies.get('google_oauth_state');
if (!storedRaw || !state || !code) {
throw error(400, 'Invalid OAuth callback');
}
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
if (stored.state !== state) {
throw error(400, 'State mismatch');
}
cookies.delete('google_oauth_state', { path: '/' });
const anonId = stored.anonymousId;
if (!anonId) {
console.error('[Google 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 exchangeGoogleCode(
code,
`${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`
);
const claims = decodeGoogleIdToken(tokens.id_token);
const googleId = claims.sub;
// --- User resolution ---
let userId: string;
// 1. Check if a user with this googleId already exists (returning user)
const existingGoogleUser = await auth.getUserByGoogleId(googleId);
if (existingGoogleUser) {
userId = existingGoogleUser.id;
console.log(`[Google auth] Returning Google user: userId=${userId}, anonId=${anonId}`);
await auth.migrateAnonymousStats(anonId, userId);
} else if (claims.email) {
// 2. Check if email matches an existing email/password or Apple user
const existingEmailUser = await auth.getUserByEmail(claims.email);
if (existingEmailUser) {
// Link Google account to existing user
await db.update(userTable).set({ googleId }).where(eq(userTable.id, existingEmailUser.id));
userId = existingEmailUser.id;
console.log(`[Google auth] Linked Google to existing email user: userId=${userId}, anonId=${anonId}`);
await auth.migrateAnonymousStats(anonId, userId);
} else {
// 3. Brand new user — use anonymousId as user ID to preserve local stats
userId = anonId;
console.log(`[Google auth] New user (has email): userId=${userId}`);
try {
await db.insert(userTable).values({
id: userId,
email: claims.email,
passwordHash: null,
appleId: null,
googleId,
firstName: claims.given_name || null,
lastName: claims.family_name || null,
isPrivate: false
});
} catch (e: any) {
// Handle race condition: if googleId was inserted between our check and insert
if (e?.message?.includes('UNIQUE constraint')) {
const retryUser = await auth.getUserByGoogleId(googleId);
if (retryUser) {
userId = retryUser.id;
console.log(`[Google auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
await auth.migrateAnonymousStats(anonId, userId);
} else {
throw error(500, 'Failed to create user');
}
} else {
throw e;
}
}
}
} else {
// No email from Google (edge case — Google almost always returns email)
userId = anonId;
console.log(`[Google auth] New user (no email): userId=${userId}`);
try {
await db.insert(userTable).values({
id: userId,
email: null,
passwordHash: null,
appleId: null,
googleId,
firstName: claims.given_name || null,
lastName: claims.family_name || null,
isPrivate: false
});
} catch (e: any) {
if (e?.message?.includes('UNIQUE constraint')) {
const retryUser = await auth.getUserByGoogleId(googleId);
if (retryUser) {
userId = retryUser.id;
console.log(`[Google auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
await auth.migrateAnonymousStats(anonId, userId);
} else {
throw error(500, 'Failed to create user');
}
} else {
throw e;
}
}
}
// Create session
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
redirect(302, '/');
};
+484
View File
@@ -0,0 +1,484 @@
import { db } from '$lib/server/db';
import { dailyCompletions, user } from '$lib/server/db/schema';
import { eq, gte, count, countDistinct, avg, asc, min } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
function estDateStr(daysAgo = 0): string {
const estNow = new Date(Date.now() - 5 * 60 * 60 * 1000); // UTC-5
estNow.setUTCDate(estNow.getUTCDate() - daysAgo);
return estNow.toISOString().slice(0, 10);
}
function prevDay(d: string): string {
const dt = new Date(d + 'T00:00:00Z');
dt.setUTCDate(dt.getUTCDate() - 1);
return dt.toISOString().slice(0, 10);
}
function addDays(d: string, n: number): string {
const dt = new Date(d + 'T00:00:00Z');
dt.setUTCDate(dt.getUTCDate() + n);
return dt.toISOString().slice(0, 10);
}
export const load: PageServerLoad = async () => {
const todayEst = estDateStr(0);
const yesterdayEst = estDateStr(1);
const sevenDaysAgo = estDateStr(7);
// Three weekly windows for first + second derivative calculations
// Week A: last 7 days (indices 06)
// Week B: 713 days ago (indices 713)
// Week C: 1420 days ago (indices 1420)
const weekAStart = estDateStr(6);
const weekBEnd = estDateStr(7);
const weekBStart = estDateStr(13);
const weekCEnd = estDateStr(14);
const weekCStart = estDateStr(20);
// ── Scalar stats ──────────────────────────────────────────────────────────
const [{ todayCount }] = await db
.select({ todayCount: count() })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const [{ totalCount }] = await db
.select({ totalCount: count() })
.from(dailyCompletions);
const [{ uniquePlayers }] = await db
.select({ uniquePlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions);
const [{ weeklyPlayers }] = await db
.select({ weeklyPlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, sevenDaysAgo));
const thirtyDaysAgo = estDateStr(30);
const [{ monthlyPlayers }] = await db
.select({ monthlyPlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, thirtyDaysAgo));
const todayPlayers = await db
.selectDistinct({ id: dailyCompletions.anonymousId })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const yesterdayPlayers = await db
.selectDistinct({ id: dailyCompletions.anonymousId })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, yesterdayEst));
const todaySet = new Set(todayPlayers.map((r) => r.id));
const activeStreaks = yesterdayPlayers.filter((r) => todaySet.has(r.id)).length;
const [{ avgGuessesRaw }] = await db
.select({ avgGuessesRaw: avg(dailyCompletions.guessCount) })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const avgGuessesToday = avgGuessesRaw != null ? parseFloat(avgGuessesRaw) : null;
const [{ registeredUsers }] = await db
.select({ registeredUsers: count() })
.from(user);
const avgCompletionsPerPlayer =
uniquePlayers > 0 ? Math.round((totalCount / uniquePlayers) * 100) / 100 : null;
// ── 21-day completions per day (covers all three weekly windows) ──────────
const rawPerDay21 = await db
.select({ date: dailyCompletions.date, dayCount: count() })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, weekCStart))
.groupBy(dailyCompletions.date)
.orderBy(asc(dailyCompletions.date));
const counts21 = new Map(rawPerDay21.map((r) => [r.date, r.dayCount]));
// Build indexed array: index 0 = today, index 20 = 20 days ago
const completionsPerDay: number[] = [];
for (let i = 0; i <= 20; i++) {
completionsPerDay.push(counts21.get(estDateStr(i)) ?? 0);
}
// last14Days for the trend chart (most recent first)
const last14Days: { date: string; count: number }[] = [];
for (let i = 0; i <= 13; i++) {
last14Days.push({ date: estDateStr(i), count: completionsPerDay[i] });
}
// Weekly totals from the indexed array
const weekATotal = completionsPerDay.slice(0, 7).reduce((a, b) => a + b, 0);
const weekBTotal = completionsPerDay.slice(7, 14).reduce((a, b) => a + b, 0);
const weekCTotal = completionsPerDay.slice(14, 21).reduce((a, b) => a + b, 0);
// First derivative: avg daily completions change (week A vs week B)
const completionsVelocity = Math.round(((weekATotal - weekBTotal) / 7) * 10) / 10;
// Second derivative: is velocity itself increasing or decreasing?
const completionsAcceleration =
Math.round((((weekATotal - weekBTotal) - (weekBTotal - weekCTotal)) / 7) * 10) / 10;
// ── 90-day per-user data (reused for streaks + weekly user sets) ──────────
const ninetyDaysAgo = estDateStr(90);
const recentCompletions = await db
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, ninetyDaysAgo))
.orderBy(asc(dailyCompletions.date));
// Group dates by user (ascending) and users by date
const userDatesMap = new Map<string, string[]>();
const dateUsersMap = new Map<string, Set<string>>();
for (const row of recentCompletions) {
const arr = userDatesMap.get(row.anonymousId);
if (arr) arr.push(row.date);
else userDatesMap.set(row.anonymousId, [row.date]);
let s = dateUsersMap.get(row.date);
if (!s) { s = new Set(); dateUsersMap.set(row.date, s); }
s.add(row.anonymousId);
}
// ── Streak distribution ───────────────────────────────────────────────────
const streakDistribution = new Map<number, number>();
for (const dates of userDatesMap.values()) {
const desc = dates.slice().reverse();
if (desc[0] !== todayEst && desc[0] !== yesterdayEst) continue;
let streak = 1;
let cur = desc[0];
for (let i = 1; i < desc.length; i++) {
if (desc[i] === prevDay(cur)) {
streak++;
cur = desc[i];
} else {
break;
}
}
if (streak >= 2) {
streakDistribution.set(streak, (streakDistribution.get(streak) ?? 0) + 1);
}
}
const streakChart = Array.from(streakDistribution.entries())
.sort((a, b) => a[0] - b[0])
.map(([days, userCount]) => ({ days, count: userCount }));
// ── Weekly user sets (for user-based velocity + churn) ───────────────────
const weekAUsers = new Set<string>();
const weekBUsers = new Set<string>();
const weekCUsers = new Set<string>();
for (const [userId, dates] of userDatesMap) {
if (dates.some((d) => d >= weekAStart)) weekAUsers.add(userId);
if (dates.some((d) => d >= weekBStart && d <= weekBEnd)) weekBUsers.add(userId);
if (dates.some((d) => d >= weekCStart && d <= weekCEnd)) weekCUsers.add(userId);
}
// First derivative: weekly unique users change
const userVelocity = weekAUsers.size - weekBUsers.size;
// Second derivative: is user growth speeding up or slowing down?
const userAcceleration =
weekAUsers.size - weekBUsers.size - (weekBUsers.size - weekCUsers.size);
// ── New players + churn ───────────────────────────────────────────────────
// New players: anonymousIds whose first-ever completion falls in the last 7 days.
// Checking against all-time data (not just the 90-day window) ensures accuracy.
const firstDates = await db
.select({
anonymousId: dailyCompletions.anonymousId,
firstDate: min(dailyCompletions.date),
totalCompletions: count()
})
.from(dailyCompletions)
.groupBy(dailyCompletions.anonymousId);
const newUsers7d = firstDates.filter((r) => r.firstDate != null && r.firstDate >= weekAStart).length;
// Churned: played in week B but not at all in week A
const churned7d = [...weekBUsers].filter((id) => !weekAUsers.has(id)).length;
// Net growth = truly new arrivals minus departures
const netGrowth7d = newUsers7d - churned7d;
// ── Session depth funnel ──────────────────────────────────────────────────
// For each depth d, count players with >= d completions.
// returnRate at depth d = (players with >= d+1) / (players with >= d).
const depthCounts = new Map<number, number>();
for (const r of firstDates) {
const n = r.totalCompletions;
for (let d = 1; d <= n; d++) {
depthCounts.set(d, (depthCounts.get(d) ?? 0) + 1);
}
}
const sessionDepthCards = [2, 3, 4, 5, 7].map((d) => {
const atD = depthCounts.get(d) ?? 0;
const atDplus1 = depthCounts.get(d + 1) ?? 0;
return {
depth: d,
players: atD,
returnRate: atD >= 3 ? Math.round((atDplus1 / atD) * 1000) / 10 : null
};
});
// ── Return rate ───────────────────────────────────────────────────────────
// "Return rate": % of all-time unique players who have ever played more than once.
const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length;
const overallReturnRate =
firstDates.length > 0
? Math.round((playersWithReturn / firstDates.length) * 1000) / 10
: null;
// Daily new-player return rate: for each day D, what % of first-time players
// on D ever came back (i.e. totalCompletions >= 2)?
const dailyNewPlayerReturn = new Map<string, { cohort: number; returned: number }>();
for (const r of firstDates) {
if (!r.firstDate) continue;
const existing = dailyNewPlayerReturn.get(r.firstDate) ?? { cohort: 0, returned: 0 };
existing.cohort++;
if (r.totalCompletions >= 2) existing.returned++;
dailyNewPlayerReturn.set(r.firstDate, existing);
}
// Build chronological array of daily rates (oldest first, days 60→1 ago)
// Days with fewer than 3 new players get rate=null to exclude from rolling avg
const dailyReturnRates: { date: string; cohort: number; rate: number | null }[] = [];
for (let i = 60; i >= 1; i--) {
const dateD = estDateStr(i);
const d = dailyNewPlayerReturn.get(dateD);
dailyReturnRates.push({
date: dateD,
cohort: d?.cohort ?? 0,
rate: d && d.cohort >= 3 ? Math.round((d.returned / d.cohort) * 1000) / 10 : null
});
}
// 7-day trailing rolling average of the daily rates
// Index 0 = 60 days ago, index 59 = yesterday
const newPlayerReturnSeries = dailyReturnRates.map((r, idx) => {
const window = dailyReturnRates
.slice(Math.max(0, idx - 6), idx + 1)
.filter((d) => d.rate !== null);
const avg =
window.length > 0
? Math.round((window.reduce((sum, d) => sum + (d.rate ?? 0), 0) / window.length) * 10) /
10
: null;
return { date: r.date, cohort: r.cohort, rate: r.rate, rollingAvg: avg };
});
// Velocity: avg of last 7 complete days (idx 5359) vs prior 7 (idx 4652)
const recentWindow = newPlayerReturnSeries.slice(53).filter((d) => d.rate !== null);
const priorWindow = newPlayerReturnSeries.slice(46, 53).filter((d) => d.rate !== null);
const current7dReturnAvg =
recentWindow.length > 0
? Math.round(
(recentWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / recentWindow.length) * 10
) / 10
: null;
const prior7dReturnAvg =
priorWindow.length > 0
? Math.round(
(priorWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / priorWindow.length) * 10
) / 10
: null;
const returnRateChange =
current7dReturnAvg !== null && prior7dReturnAvg !== null
? Math.round((current7dReturnAvg - prior7dReturnAvg) * 10) / 10
: null;
// ── Retention over time ───────────────────────────────────────────────────
// For each cohort day D, retention = % of that day's players who played
// again within the next N days. Only compute for days where D+N is in the past.
function retentionSeries(
windowDays: number,
seriesLength: number
): { date: string; rate: number; cohortSize: number }[] {
// Earliest computable cohort day: today - (windowDays + 1)
// We use index windowDays+1 through windowDays+seriesLength
const series: { date: string; rate: number; cohortSize: number }[] = [];
for (let i = windowDays + 1; i <= windowDays + seriesLength; i++) {
const dateD = estDateStr(i);
const cohort = dateUsersMap.get(dateD);
if (!cohort || cohort.size < 3) continue; // skip tiny cohorts
let retained = 0;
for (const userId of cohort) {
if (dateUsersMap.get(addDays(dateD, windowDays))?.has(userId)) {
retained++;
}
}
series.push({
date: dateD,
rate: Math.round((retained / cohort.size) * 1000) / 10,
cohortSize: cohort.size
});
}
return series; // newest first (loop iterates i from smallest = most recent)
}
const retention7dSeries = retentionSeries(7, 30);
const retention30dSeries = retentionSeries(30, 30);
// ── Weekly Active Users history (12 weeks) ────────────────────────────────
const wauWeeks: { weekStart: string; weekEnd: string; wau: number; changePct: number | null }[] = [];
for (let w = 0; w < 12; w++) {
const weekEnd = estDateStr(w * 7);
const weekStart = estDateStr(w * 7 + 6);
const users = new Set<string>();
for (const row of recentCompletions) {
if (row.date >= weekStart && row.date <= weekEnd) {
users.add(row.anonymousId);
}
}
wauWeeks.push({ weekEnd, weekStart, wau: users.size, changePct: null });
}
// Change % vs prior week (index i+1 is the older week)
for (let i = 0; i < wauWeeks.length - 1; i++) {
const prev = wauWeeks[i + 1].wau;
if (prev > 0) {
wauWeeks[i].changePct = Math.round(((wauWeeks[i].wau - prev) / prev) * 1000) / 10;
}
}
const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length);
// ── Monthly Active Users history (6 months) ───────────────────────────────
const sixMonthsAgo = estDateStr(185);
const mauCompletions = await db
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, sixMonthsAgo));
// Rolling 30-day windows
const mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[] = [];
for (let m = 0; m < 6; m++) {
const monthEnd = estDateStr(m * 30);
const monthStart = estDateStr(m * 30 + 29);
const users = new Set<string>();
for (const row of mauCompletions) {
if (row.date >= monthStart && row.date <= monthEnd) {
users.add(row.anonymousId);
}
}
mauMonths.push({ monthStart, monthEnd, mau: users.size, changePct: null });
}
for (let i = 0; i < mauMonths.length - 1; i++) {
const prev = mauMonths[i + 1].mau;
if (prev > 0) {
mauMonths[i].changePct = Math.round(((mauMonths[i].mau - prev) / prev) * 1000) / 10;
}
}
// Calendar month windows
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const [todayYear, todayMonth, todayDay] = todayEst.split('-').map(Number);
const calendarMauMonths: {
label: string;
monthStart: string;
monthEnd: string;
mau: number;
daysElapsed: number;
daysInMonth: number;
projectedMau: number | null;
changePct: number | null;
isCurrentMonth: boolean;
}[] = [];
for (let i = 0; i < 6; i++) {
let mo = todayMonth - i;
let yr = todayYear;
if (mo <= 0) { mo += 12; yr--; }
// new Date(yr, mo, 0) gives last day of month mo (1-indexed) in local time
const daysInMonth = new Date(yr, mo, 0).getDate();
const monthStart = `${yr}-${String(mo).padStart(2, '0')}-01`;
const monthEnd = `${yr}-${String(mo).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
const isCurrentMonth = i === 0;
const daysElapsed = isCurrentMonth ? todayDay : daysInMonth;
const queryEnd = isCurrentMonth ? todayEst : monthEnd;
const users = new Set<string>();
for (const row of mauCompletions) {
if (row.date >= monthStart && row.date <= queryEnd) {
users.add(row.anonymousId);
}
}
const projectedMau = isCurrentMonth && daysElapsed > 0
? Math.round(users.size * (daysInMonth / daysElapsed))
: null;
calendarMauMonths.push({
label: `${MONTH_NAMES[mo - 1]} ${yr}`,
monthStart,
monthEnd,
mau: users.size,
daysElapsed,
daysInMonth,
projectedMau,
changePct: null,
isCurrentMonth
});
}
for (let i = 0; i < calendarMauMonths.length - 1; i++) {
const curr = calendarMauMonths[i];
const prev = calendarMauMonths[i + 1];
if (prev.mau > 0) {
const compareVal = curr.projectedMau ?? curr.mau;
curr.changePct = Math.round(((compareVal - prev.mau) / prev.mau) * 1000) / 10;
}
}
return {
todayEst,
sessionDepthCards,
stats: {
todayCount,
totalCount,
uniquePlayers,
weeklyPlayers,
activeStreaks,
avgGuessesToday,
registeredUsers,
monthlyPlayers
},
growth: {
completionsVelocity,
completionsAcceleration,
userVelocity,
userAcceleration,
newUsers7d,
churned7d,
netGrowth7d
},
last14Days,
streakChart,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30).reverse(),
newPlayerReturnVelocity: {
current7dAvg: current7dReturnAvg,
prior7dAvg: prior7dReturnAvg,
change: returnRateChange
},
wauWeeks,
avgWau,
mauMonths,
calendarMauMonths
};
};
+627
View File
@@ -0,0 +1,627 @@
<script lang="ts">
import Container from "$lib/components/Container.svelte";
import CollapsibleTable from "$lib/components/CollapsibleTable.svelte";
interface Stats {
todayCount: number;
totalCount: number;
uniquePlayers: number;
weeklyPlayers: number;
activeStreaks: number;
avgGuessesToday: number | null;
registeredUsers: number;
monthlyPlayers: number;
}
interface PageData {
todayEst: string;
stats: Stats;
last14Days: { date: string; count: number }[];
streakChart: { days: number; count: number }[];
growth: {
completionsVelocity: number;
completionsAcceleration: number;
userVelocity: number;
userAcceleration: number;
newUsers7d: number;
churned7d: number;
netGrowth7d: number;
};
retention7dSeries: { date: string; rate: number; cohortSize: number }[];
retention30dSeries: {
date: string;
rate: number;
cohortSize: number;
}[];
overallReturnRate: number | null;
newPlayerReturnSeries: {
date: string;
cohort: number;
rate: number | null;
rollingAvg: number | null;
}[];
newPlayerReturnVelocity: {
current7dAvg: number | null;
prior7dAvg: number | null;
change: number | null;
};
wauWeeks: {
weekStart: string;
weekEnd: string;
wau: number;
changePct: number | null;
}[];
avgWau: number;
mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[];
calendarMauMonths: {
label: string;
monthStart: string;
monthEnd: string;
mau: number;
daysElapsed: number;
daysInMonth: number;
projectedMau: number | null;
changePct: number | null;
isCurrentMonth: boolean;
}[];
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
}
let { data }: { data: PageData } = $props();
const {
stats,
last14Days,
todayEst,
streakChart,
growth,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries,
newPlayerReturnVelocity,
wauWeeks,
avgWau,
mauMonths,
calendarMauMonths,
sessionDepthCards,
} = $derived(data);
let mauMode = $state<'rolling' | 'calendar'>('rolling');
function signed(n: number, unit = ""): string {
if (n > 0) return `+${n}${unit}`;
if (n < 0) return `${n}${unit}`;
return `0${unit}`;
}
function trendColor(n: number): string {
if (n > 0) return "text-green-400";
if (n < 0) return "text-red-400";
return "text-gray-400";
}
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
const maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
const statCards = $derived([
{ label: "Completions Today", value: String(stats.todayCount) },
{ label: "All-Time Completions", value: String(stats.totalCount) },
{ label: "Unique Players", value: String(stats.uniquePlayers) },
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
{ label: "Active Streaks", value: String(stats.activeStreaks) },
{ label: "Registered Users", value: String(stats.registeredUsers) },
{ label: "Players This Month", value: String(stats.monthlyPlayers) },
{
label: "Overall Return Rate",
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
},
]);
const mauModes = [
{ value: 'rolling', label: 'Rolling 30d' },
{ value: 'calendar', label: 'Calendar' },
];
</script>
<svelte:head>
<title>Global Stats | Bibdle</title>
</svelte:head>
<div
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100"
>
<div class="max-w-6xl mx-auto px-4 py-8">
<a
href="/"
class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors"
>
← Back to Game
</a>
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
<p class="text-gray-400 text-sm mt-1">
EST reference date: {todayEst}
</p>
</header>
<section
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10"
>
{#each statCards as card (card.label)}
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>{card.label}</span
>
<span class="text-2xl md:text-3xl font-bold text-gray-100"
>{card.value}</span
>
</Container>
{/each}
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Traffic &amp; Growth <span
class="text-xs font-normal text-gray-400"
>(7-day windows)</span
>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Completions Velocity</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.completionsVelocity,
)}">{signed(growth.completionsVelocity, "/day")}</span
>
<span class="text-xs text-gray-500 text-center"
>vs prior 7 days</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Completions Accel.</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.completionsAcceleration,
)}"
>{signed(growth.completionsAcceleration, "/day")}</span
>
<span class="text-xs text-gray-500 text-center"
>rate of change of velocity</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>User Velocity</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.userVelocity,
)}">{signed(growth.userVelocity)}</span
>
<span class="text-xs text-gray-500 text-center"
>unique players, wk/wk</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>User Acceleration</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.userAcceleration,
)}">{signed(growth.userAcceleration)}</span
>
<span class="text-xs text-gray-500 text-center"
>rate of change of user velocity</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>New Players (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.newUsers7d,
)}">{String(growth.newUsers7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>first-time players</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Churned (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
0,
)}">{String(growth.churned7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>played wk prior, not this wk</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Net Growth (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.netGrowth7d,
)}">{signed(growth.netGrowth7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>new minus churned</span
>
</Container>
</div>
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-1">Survival Curve</h2>
<p class="text-gray-400 text-sm mb-4">
Of players who completed N sessions, what % came back for N+1?
</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{#each sessionDepthCards as card (card.depth)}
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center"
>After {card.depth} plays</span
>
<span class="text-2xl md:text-3xl font-bold text-gray-100">
{card.returnRate != null ? `${card.returnRate}%` : "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>{card.players} players</span
>
</Container>
{/each}
</div>
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
New Player Return Rate <span
class="text-xs font-normal text-gray-400"
>(7-day rolling avg)</span
>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Return Rate (7d avg)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
>
{newPlayerReturnVelocity.current7dAvg != null
? `${newPlayerReturnVelocity.current7dAvg}%`
: "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>new players who came back</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Return Rate Change</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {newPlayerReturnVelocity.change !=
null
? trendColor(newPlayerReturnVelocity.change)
: 'text-gray-400'}"
>
{newPlayerReturnVelocity.change != null
? signed(newPlayerReturnVelocity.change, "pp")
: "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>vs prior 7 days</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Prior 7d Avg</span
>
<span
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
>
{newPlayerReturnVelocity.prior7dAvg != null
? `${newPlayerReturnVelocity.prior7dAvg}%`
: "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>days 814 ago</span
>
</Container>
</div>
<CollapsibleTable
rows={newPlayerReturnSeries}
headers={[
{ label: 'Date' },
{ label: 'New Players', align: 'right' },
{ label: 'Return Rate', align: 'right' },
{ label: '7d Avg', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
<td class="px-4 py-3 text-right text-gray-400">
{item.rate != null ? `${item.rate}%` : '—'}
</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">
{item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
{#if item.rollingAvg != null}
<div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
{/if}
</div>
</td>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-1">
Weekly Active Users
</h2>
<p class="text-gray-400 text-sm mb-4">
Unique players per 7-day window. Most recent week first. Avg
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
</p>
<CollapsibleTable
rows={wauWeeks}
headers={[
{ label: 'Week' },
{ label: 'Active Users', align: 'right' },
{ label: 'Wk/Wk Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.wau / maxWau) * 100)}
<td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} {item.weekEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{item.changePct != null ? signed(item.changePct, '%') : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-indigo-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
<p class="text-gray-400 text-sm mb-4">
{mauMode === 'rolling'
? 'Unique players per 30-day window. Most recent first.'
: 'Unique players per calendar month. Current month projected to end of month.'}
</p>
{#if mauMode === 'rolling'}
<CollapsibleTable
rows={mauMonths}
headers={[
{ label: 'Period' },
{ label: 'Active Users', align: 'right' },
{ label: 'Mo/Mo Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
modes={mauModes}
bind:mode={mauMode}
>
{#snippet row(item)}
{@const barPct = Math.round((item.mau / maxMau) * 100)}
<td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} {item.monthEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{item.changePct != null ? signed(item.changePct, '%') : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
{:else}
<CollapsibleTable
rows={calendarMauMonths}
headers={[
{ label: 'Month' },
{ label: 'Active Users', align: 'right' },
{ label: 'Mo/Mo Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
modes={mauModes}
bind:mode={mauMode}
>
{#snippet row(item)}
{@const displayMau = item.projectedMau ?? item.mau}
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
<td class="px-4 py-3 text-gray-300">
{item.label}
{#if item.isCurrentMonth}
<span class="text-xs text-gray-500 ml-1">(projected)</span>
{/if}
</td>
<td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
{#if item.isCurrentMonth}
<span class="text-gray-500 text-xs">{item.mau}</span>{item.projectedMau ?? item.mau}
{:else}
{item.mau}
{/if}
</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{#if item.changePct != null}
{item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')}
{:else}
{/if}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-teal-500 h-4 rounded {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
{/if}
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Last 14 Days — Completions
</h2>
<CollapsibleTable
rows={last14Days}
headers={[
{ label: 'Date' },
{ label: 'Completions', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.count / maxCount) * 100)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Active Streak Distribution
</h2>
<CollapsibleTable
rows={streakChart}
headers={[
{ label: 'Days' },
{ label: 'Players', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.count / maxStreakCount) * 100)}
<td class="px-4 py-3 text-gray-300">{item.days}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
{#snippet empty()}
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-1">
Retention Over Time
</h2>
<p class="text-gray-400 text-sm mb-6">
% of each day's players who played again exactly 7 or 30 days later (regardless of activity in between). Cohorts with fewer than 3 players are excluded.
</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 7-day retention -->
<div>
<h3 class="text-base font-semibold text-gray-200 mb-3">
7-Day Retention
</h3>
<CollapsibleTable
rows={retention7dSeries}
headers={[
{ label: 'Cohort Date' },
{ label: 'n', align: 'right' },
{ label: 'Ret. %', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div class="bg-emerald-500 h-4 rounded" style="width: {item.rate}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
</div>
<!-- 30-day retention -->
<div>
<h3 class="text-base font-semibold text-gray-200 mb-3">
30-Day Retention
</h3>
<CollapsibleTable
rows={retention30dSeries}
headers={[
{ label: 'Cohort Date' },
{ label: 'n', align: 'right' },
{ label: 'Ret. %', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div class="bg-violet-500 h-4 rounded" style="width: {item.rate}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
</div>
</div>
</section>
</div>
</div>
+287
View File
@@ -0,0 +1,287 @@
import { db } from '$lib/server/db';
import { dailyCompletions, dailyVerses } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { bibleBooks } from '$lib/types/bible';
import { calculateMilestones } from '$lib/server/milestones';
import type { Milestone } from '$lib/server/milestones';
export type BookTier = 'unseen' | 'explored' | 'mastered' | 'perfect';
export type BookGridEntry = {
bookId: string;
tier: BookTier;
avgGuesses: number | null;
count: number;
};
export type ChartPoint = {
label: string;
avgGuesses: number;
};
export type SectionStat = {
section: string;
avgGuesses: number;
count: number;
};
export type TestamentStat = {
avgGuesses: number;
count: number;
} | null;
export type { Milestone };
export type ProgressData = {
completions: Array<{ date: string; guessCount: number }>;
chartPoints: ChartPoint[];
bookGrid: BookGridEntry[];
sectionStats: SectionStat[];
testamentStats: { old: TestamentStat; new: TestamentStat };
totalSolves: number;
bestStreak: number;
currentStreak: number;
booksExplored: number;
booksMastered: number;
booksPerfect: number;
bestSingleGame: { date: string; bookName: string } | null;
totalWords: number;
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
milestones: Milestone[];
};
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
return {
progress: null,
requiresAuth: true,
user: null,
session: null,
};
}
const userId = locals.user.id;
try {
const completions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId))
.orderBy(desc(dailyCompletions.date));
if (completions.length === 0) {
return {
progress: {
completions: [],
chartPoints: [],
bookGrid: bibleBooks.map(b => ({ bookId: b.id, tier: 'unseen' as BookTier, avgGuesses: null, count: 0 })),
sectionStats: [],
testamentStats: { old: null, new: null },
totalSolves: 0,
bestStreak: 0,
currentStreak: 0,
booksExplored: 0,
booksMastered: 0,
booksPerfect: 0,
bestSingleGame: null,
totalWords: 0,
streakMilestones: { days7: null, days14: null, days30: null },
milestones: [],
} satisfies ProgressData,
requiresAuth: false,
user: locals.user,
session: locals.session,
};
}
// Map dates to book IDs and verse text via cached daily_verses
const allVerses = await db.select().from(dailyVerses);
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
const dateToVerseText = new Map(allVerses.map(v => [v.date, v.verseText]));
// Total words across all played verses
let totalWords = 0;
for (const c of completions) {
const verseText = dateToVerseText.get(c.date);
if (verseText) {
totalWords += verseText.trim().split(/\s+/).length;
}
}
// Per-book stats
const bookStatsMap = new Map<string, { count: number; totalGuesses: number; everGuessedIn1: boolean }>();
for (const c of completions) {
const bookId = dateToBookId.get(c.date);
if (!bookId) continue;
const existing = bookStatsMap.get(bookId) ?? { count: 0, totalGuesses: 0, everGuessedIn1: false };
bookStatsMap.set(bookId, {
count: existing.count + 1,
totalGuesses: existing.totalGuesses + c.guessCount,
everGuessedIn1: existing.everGuessedIn1 || c.guessCount === 1,
});
}
// Book grid (all 66 in canonical order)
const bookGrid: BookGridEntry[] = bibleBooks.map(book => {
const stats = bookStatsMap.get(book.id);
if (!stats) return { bookId: book.id, tier: 'unseen', avgGuesses: null, count: 0 };
const avgGuesses = stats.totalGuesses / stats.count;
let tier: BookTier = 'explored';
if (stats.count >= 2 && avgGuesses <= 3) {
tier = stats.everGuessedIn1 ? 'perfect' : 'mastered';
}
return { bookId: book.id, tier, avgGuesses: Math.round(avgGuesses * 10) / 10, count: stats.count };
});
// Section stats
const sectionMap = new Map<string, { totalGuesses: number; count: number }>();
for (const c of completions) {
const bookId = dateToBookId.get(c.date);
if (!bookId) continue;
const book = bibleBooks.find(b => b.id === bookId);
if (!book) continue;
const existing = sectionMap.get(book.section) ?? { totalGuesses: 0, count: 0 };
sectionMap.set(book.section, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
}
const sectionStats: SectionStat[] = Array.from(sectionMap.entries())
.filter(([, s]) => s.count >= 3)
.map(([section, s]) => ({ section, avgGuesses: Math.round((s.totalGuesses / s.count) * 10) / 10, count: s.count }))
.sort((a, b) => a.avgGuesses - b.avgGuesses);
// Testament stats (only show if ≥5 games per testament)
let otTotal = 0, otCount = 0, ntTotal = 0, ntCount = 0;
for (const c of completions) {
const bookId = dateToBookId.get(c.date);
if (!bookId) continue;
const book = bibleBooks.find(b => b.id === bookId);
if (!book) continue;
if (book.testament === 'old') { otTotal += c.guessCount; otCount++; }
else { ntTotal += c.guessCount; ntCount++; }
}
const testamentStats = {
old: otCount >= 5 ? { avgGuesses: Math.round((otTotal / otCount) * 10) / 10, count: otCount } : null,
new: ntCount >= 5 ? { avgGuesses: Math.round((ntTotal / ntCount) * 10) / 10, count: ntCount } : null,
};
// Chart points — monthly averages sorted ascending
const sortedCompletions = [...completions].sort((a, b) => a.date.localeCompare(b.date));
const monthMap = new Map<string, { totalGuesses: number; count: number }>();
for (const c of sortedCompletions) {
const month = c.date.slice(0, 7); // YYYY-MM
const existing = monthMap.get(month) ?? { totalGuesses: 0, count: 0 };
monthMap.set(month, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
}
let chartPoints: ChartPoint[] = Array.from(monthMap.entries())
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
// Fall back to weekly if fewer than 3 months of data
if (chartPoints.length < 3 && sortedCompletions.length >= 5) {
const weekMap = new Map<string, { totalGuesses: number; count: number }>();
for (const c of sortedCompletions) {
const d = new Date(c.date + 'T00:00:00Z');
const year = d.getUTCFullYear();
const week = getISOWeek(d);
const key = `${year}-W${String(week).padStart(2, '0')}`;
const existing = weekMap.get(key) ?? { totalGuesses: 0, count: 0 };
weekMap.set(key, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
}
chartPoints = Array.from(weekMap.entries())
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
}
// Best streak (all-time) + streak milestones
const sortedDates = completions.map(c => c.date).sort();
let bestStreak = sortedDates.length > 0 ? 1 : 0;
let tempStreak = 1;
const streakMilestones: { days7: string | null; days14: string | null; days30: string | null } = { days7: null, days14: null, days30: null };
for (let i = 1; i < sortedDates.length; i++) {
const curr = new Date(sortedDates[i] + 'T00:00:00Z');
const prev = new Date(sortedDates[i - 1] + 'T00:00:00Z');
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
if (diff === 1) { tempStreak++; }
else { bestStreak = Math.max(bestStreak, tempStreak); tempStreak = 1; }
if (tempStreak >= 7 && !streakMilestones.days7) streakMilestones.days7 = sortedDates[i];
if (tempStreak >= 14 && !streakMilestones.days14) streakMilestones.days14 = sortedDates[i];
if (tempStreak >= 30 && !streakMilestones.days30) streakMilestones.days30 = sortedDates[i];
}
bestStreak = Math.max(bestStreak, tempStreak);
// Server-side current streak estimate (client overrides via /api/streak)
const userToday = new Date().toISOString().slice(0, 10);
const yesterday = new Date(new Date(userToday + 'T00:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
const lastDate = sortedDates[sortedDates.length - 1] ?? '';
let currentStreak = 0;
if (lastDate === userToday || lastDate === yesterday) {
currentStreak = 1;
for (let i = sortedDates.length - 2; i >= 0; i--) {
const curr = new Date(sortedDates[i + 1] + 'T00:00:00Z');
const prev = new Date(sortedDates[i] + 'T00:00:00Z');
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
if (diff === 1) currentStreak++;
else break;
}
}
// Milestone counts
const booksExplored = bookStatsMap.size;
const booksMastered = bookGrid.filter(b => b.tier === 'mastered' || b.tier === 'perfect').length;
const booksPerfect = bookGrid.filter(b => b.tier === 'perfect').length;
// Best single game (earliest 1-guess solve)
let bestSingleGame: { date: string; bookName: string } | null = null;
for (const c of sortedCompletions) {
if (c.guessCount === 1) {
const bookId = dateToBookId.get(c.date);
const book = bookId ? bibleBooks.find(b => b.id === bookId) : null;
if (book) {
bestSingleGame = { date: c.date, bookName: book.name };
break;
}
}
}
const milestones = await calculateMilestones(completions, dateToBookId, { bestSingleGame, streakMilestones });
return {
progress: {
completions: completions.map(c => ({ date: c.date, guessCount: c.guessCount })),
chartPoints,
bookGrid,
sectionStats,
testamentStats,
totalSolves: completions.length,
bestStreak,
currentStreak,
booksExplored,
booksMastered,
booksPerfect,
bestSingleGame,
totalWords,
streakMilestones,
milestones,
} satisfies ProgressData,
requiresAuth: false,
user: locals.user,
session: locals.session,
};
} catch (error) {
console.error('Error fetching progress data:', error);
return {
progress: null,
error: 'Failed to load progress data',
requiresAuth: false,
user: locals.user,
session: locals.session,
};
}
};
function getISOWeek(d: Date): number {
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const dayNum = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}
+627
View File
@@ -0,0 +1,627 @@
<script lang="ts">
import { browser } from "$app/environment";
import { onMount } from "svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
import Container from "$lib/components/Container.svelte";
import ProgressStatCard from "$lib/components/ProgressStatCard.svelte";
import ActivityCalendar from "$lib/components/ActivityCalendar.svelte";
import { bibleBooks } from "$lib/types/bible";
type BookTier = "unseen" | "explored" | "mastered" | "perfect";
type BookGridEntry = {
bookId: string;
tier: BookTier;
avgGuesses: number | null;
count: number;
};
type ChartPoint = {
label: string;
avgGuesses: number;
};
type SectionStat = {
section: string;
avgGuesses: number;
count: number;
};
type Milestone = {
id: string;
name: string;
emoji: string;
description: string;
achieved: boolean;
achievedDate: string | null;
};
type ProgressData = {
completions: Array<{ date: string; guessCount: number }>;
chartPoints: ChartPoint[];
bookGrid: BookGridEntry[];
sectionStats: SectionStat[];
testamentStats: {
old: { avgGuesses: number; count: number } | null;
new: { avgGuesses: number; count: number } | null;
};
totalSolves: number;
bestStreak: number;
currentStreak: number;
booksExplored: number;
booksMastered: number;
booksPerfect: number;
bestSingleGame: { date: string; bookName: string } | null;
totalWords: number;
streakMilestones: {
days7: string | null;
days14: string | null;
days30: string | null;
};
milestones: Milestone[];
};
interface PageData {
progress: ProgressData | null;
error?: string;
user?: any;
session?: any;
requiresAuth?: boolean;
}
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let anonymousId = $state("");
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
function bookTileClass(tier: BookTier): string {
switch (tier) {
case "perfect":
return "bg-emerald-500 text-white";
case "mastered":
return "bg-purple-600 text-white";
case "explored":
return "bg-blue-700 text-blue-100";
default:
return "bg-gray-700/50 text-gray-500";
}
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00Z");
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
}
onMount(() => {
anonymousId = getOrCreateAnonymousId();
});
// Derived SVG chart values
const chartPoints = $derived(data.progress?.chartPoints ?? []);
const showChart = $derived(chartPoints.length >= 3);
const maxGuesses = $derived(
showChart ? Math.max(...chartPoints.map((p) => p.avgGuesses)) : 6,
);
const chartImproving = $derived(
showChart &&
chartPoints[chartPoints.length - 1].avgGuesses <
chartPoints[0].avgGuesses,
);
function svgX(index: number, total: number): number {
return (index / (total - 1)) * 360 + 20;
}
function svgY(avgGuesses: number, maxG: number): number {
return 100 - ((maxG - avgGuesses) / (maxG - 1)) * 90 + 10;
}
const polylinePoints = $derived(
showChart
? chartPoints
.map(
(p, i) =>
`${svgX(i, chartPoints.length)},${svgY(p.avgGuesses, maxGuesses)}`,
)
.join(" ")
: "",
);
// Insights helpers
const progress = $derived(data.progress);
const bestSection = $derived(
progress?.sectionStats.find((s) => s.count >= 3) ?? null,
);
const hardestSection = $derived.by(() => {
if (!progress) return null;
const eligible = progress.sectionStats.filter((s) => s.count >= 3);
if (eligible.length === 0) return null;
const last = eligible[eligible.length - 1];
if (bestSection && last.section === bestSection.section) return null;
return last;
});
const showInsights = $derived(
progress !== null &&
((progress.testamentStats.old !== null &&
progress.testamentStats.new !== null) ||
bestSection !== null),
);
function testamentComparison(
old_: { avgGuesses: number; count: number } | null,
new_: { avgGuesses: number; count: number } | null,
): string | null {
if (!old_ || !new_) return null;
const ratio = old_.avgGuesses / new_.avgGuesses;
if (ratio < 0.85) {
const x = (new_.avgGuesses / old_.avgGuesses).toFixed(1);
return `You're ${x}x faster at Old Testament books`;
}
if (ratio > 1.18) {
const x = (old_.avgGuesses / new_.avgGuesses).toFixed(1);
return `You're ${x}x faster at New Testament books`;
}
return "Your speed is similar for Old and New Testament books";
}
</script>
<svelte:head>
<title>Your Progress | Bibdle</title>
<meta
name="description"
content="Track your Bible knowledge journey with Bibdle"
/>
</svelte:head>
<div
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
>
<div class="max-w-3xl mx-auto">
<!-- Header -->
<div class="text-center mb-6 md:mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-4">
Your Progress
</h1>
<a href="/" class="p-2 px-20 w-full items-center text-gray-300">
&larr; Back to Game
</a>
</div>
{#if data.requiresAuth}
<div class="text-center py-12">
<div
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-blue-200 mb-4">
Authentication Required
</h2>
<p class="text-blue-300 mb-6">
You must be logged in to see your progress.
</p>
<div class="flex flex-col gap-3">
<button
onclick={() => (authModalOpen = true)}
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Sign In / Sign Up
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
</div>
{:else if data.error}
<div class="text-center py-12">
<div
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
>
<p class="text-red-300">{data.error}</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Return to Game
</a>
</div>
</div>
{:else if !data.progress}
<div class="text-center py-12">
<Container class="p-8 max-w-md mx-auto">
<div class="text-yellow-400 mb-4 text-lg">
No progress yet.
</div>
<p class="text-gray-300 mb-6">
Start playing to build your Bible knowledge journey!
</p>
<a
href="/"
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
>
Start Playing
</a>
</Container>
</div>
{:else}
{@const prog = data.progress}
<!-- Key Stats Row -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mb-6">
<ProgressStatCard
emoji="📅"
value={String(prog.totalSolves)}
label="Total Played"
colorClass="text-blue-400"
/>
<ProgressStatCard
emoji="📖"
value={String(prog.booksExplored)}
label="Books Explored"
colorClass="text-teal-400"
suffix="/ 66"
/>
<ProgressStatCard
emoji="✍️"
value={prog.totalWords.toLocaleString()}
label="Words Read"
colorClass="text-violet-400"
/>
<ProgressStatCard
emoji="✝️"
value={(((prog.totalSolves * 3) / 31102) * 100).toFixed(2) +
"%"}
label="Bible Read"
colorClass="text-amber-400"
/>
<ProgressStatCard
emoji="🏆"
value={String(prog.booksMastered)}
label="Books Mastered"
colorClass="text-purple-400"
suffix="/ 66"
/>
<ProgressStatCard
emoji="⭐"
value={String(prog.booksPerfect)}
label="Books Perfected"
colorClass="text-emerald-400"
suffix="/ 66"
/>
</div>
<!-- Bible Book Grid -->
<div class="mb-6">
<Container class="p-4 md:p-6 w-full">
<h2
class="text-xl font-bold text-gray-100 mb-3 w-full text-left"
>
Bible Books
</h2>
<!-- Legend -->
<div class="flex flex-wrap gap-2 mb-3">
<span
class="flex items-center gap-1 text-xs text-gray-400"
>
<span
class="inline-block w-5 h-5 rounded bg-blue-700"
></span>
Explored
</span>
<span
class="flex items-center gap-1 text-xs text-gray-400"
>
<span
class="inline-block w-5 h-5 rounded bg-purple-600"
></span>
Mastered
</span>
<span
class="flex items-center gap-1 text-xs text-gray-400"
>
<span
class="inline-block w-5 h-5 rounded bg-emerald-500"
></span>
Perfect
</span>
</div>
<!-- Grid -->
<div class="grid grid-cols-8 md:grid-cols-11 gap-1 w-full">
{#each prog.bookGrid as entry (entry.bookId)}
{@const bookMeta = bibleBooks.find(
(b) => b.id === entry.bookId,
)}
<div
class="aspect-square flex items-center justify-center rounded text-[9px] md:text-[10px] font-bold cursor-default {bookTileClass(
entry.tier,
)}"
title="{bookMeta?.name ??
entry.bookId} — {entry.tier}{entry.avgGuesses !==
null
? ` (avg ${entry.avgGuesses})`
: ''}"
>
{entry.bookId}
</div>
{/each}
</div>
<p class="text-xs text-gray-500 mt-3 leading-relaxed">
<span class="text-blue-400 font-medium">Explored</span>
— played at least once<br />
<span class="text-purple-400 font-medium"
>Mastered</span
>
— avg &le; 3 guesses over 2+ plays<br />
<span class="text-emerald-400 font-medium">Perfect</span>
mastered and guessed in 1 at least once
</p>
</Container>
</div>
<!-- Activity Calendar -->
<div class="mb-6">
<ActivityCalendar completions={prog.completions} />
</div>
<!-- Skill Growth Chart (hidden, needs rework) -->
{#if false && showChart}
<div class="mb-6">
<Container class="p-4 md:p-6 w-full">
<div class="w-full">
<div class="flex items-baseline gap-2 mb-1">
<h2 class="text-xl font-bold text-gray-100">
Skill Growth
</h2>
<span class="text-xs text-gray-400">
Lower is better
</span>
</div>
<svg
viewBox="0 0 400 135"
class="w-full"
aria-hidden="true"
>
<defs>
<linearGradient
id="chartFill"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stop-color="#10b981"
stop-opacity="0.3"
/>
<stop
offset="100%"
stop-color="#10b981"
stop-opacity="0"
/>
</linearGradient>
</defs>
<!-- Y-axis label -->
<text
transform="translate(8, 60) rotate(-90)"
text-anchor="middle"
font-size="8"
fill="#9ca3af"
>Guesses</text>
<!-- Fill polygon -->
<polygon
points="{polylinePoints} {svgX(
chartPoints.length - 1,
chartPoints.length,
)},110 {svgX(0, chartPoints.length)},110"
fill="url(#chartFill)"
/>
<!-- Line -->
<polyline
points={polylinePoints}
fill="none"
stroke="#10b981"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Dots -->
{#each chartPoints as point, i (i)}
<circle
cx={svgX(i, chartPoints.length)}
cy={svgY(point.avgGuesses, maxGuesses)}
r="3"
fill="#10b981"
/>
<text
x={svgX(i, chartPoints.length)}
y={svgY(point.avgGuesses, maxGuesses) - 6}
font-size="7"
fill="#6ee7b7"
text-anchor="middle"
>{point.avgGuesses}</text>
{/each}
<!-- X-axis labels -->
<text
x={svgX(0, chartPoints.length)}
y="118"
font-size="8"
fill="#9ca3af"
text-anchor="middle"
>
{chartPoints[0].label}
</text>
<text
x={svgX(
chartPoints.length - 1,
chartPoints.length,
)}
y="118"
font-size="8"
fill="#9ca3af"
text-anchor="middle"
>
{chartPoints[chartPoints.length - 1].label}
</text>
<!-- X-axis title -->
<text
x="200"
y="132"
font-size="8"
fill="#9ca3af"
text-anchor="middle"
>Date</text>
</svg>
{#if chartImproving}
<p class="text-xs text-emerald-400 mt-1">
You're getting better!
</p>
{/if}
<p
class="text-xs text-gray-500 mt-2 leading-relaxed"
>
Each point is your average guesses over a
rolling window of games. A downward trend means
you're improving.
</p>
</div>
</Container>
</div>
{/if}
<!-- Achievements -->
{#if prog.milestones.length > 0}
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-100 mb-3">🏆 Achievements</h2>
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 md:gap-3">
{#each prog.milestones.filter(m => m.achieved) as milestone (milestone.id)}
<Container class="p-3 min-h-[130px]">
<div class="text-center flex flex-col items-center justify-center h-full">
<div class="text-2xl mb-1">{milestone.emoji}</div>
<div class="text-sm font-bold text-yellow-300 leading-tight mb-1">
{milestone.name}
</div>
<div class="text-xs text-gray-400 leading-tight">
{milestone.description}
</div>
{#if milestone.achievedDate}
<div class="text-[10px] text-gray-500 mt-1">
{formatDate(milestone.achievedDate)}
</div>
{:else}
<div class="text-[10px] text-emerald-500 mt-1">Earned</div>
{/if}
</div>
</Container>
{/each}
</div>
<p class="text-xs text-gray-500 mt-2">{prog.milestones.filter(m => m.achieved).length} / {prog.milestones.length} achievements unlocked</p>
</div>
{/if}
<!-- Insights -->
{#if showInsights}
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-100 mb-3">
Insights
</h2>
<div class="flex flex-col gap-3">
{#if prog.testamentStats.old && prog.testamentStats.new}
{@const comparison = testamentComparison(
prog.testamentStats.old,
prog.testamentStats.new,
)}
{#if comparison}
<Container class="p-4 w-full">
<div class="flex items-start gap-3 w-full">
<span class="text-2xl">📊</span>
<div class="flex-1 min-w-0">
<p
class="text-gray-100 font-medium text-sm"
>
{comparison}
</p>
<p
class="text-gray-400 text-xs mt-0.5"
>
OT avg: {prog.testamentStats.old
.avgGuesses} guesses &bull; NT
avg: {prog.testamentStats.new
.avgGuesses} guesses
</p>
</div>
</div>
</Container>
{/if}
{/if}
{#if bestSection && bestSection.count >= 3}
<Container class="p-4 w-full">
<div class="flex items-start gap-3 w-full">
<span class="text-2xl">🌟</span>
<div class="flex-1 min-w-0">
<p
class="text-gray-100 font-medium text-sm"
>
Your strongest section: {bestSection.section}
</p>
<p class="text-gray-400 text-xs mt-0.5">
{bestSection.avgGuesses} avg guesses
across {bestSection.count} games
</p>
</div>
</div>
</Container>
{/if}
{#if hardestSection}
{@const hard = hardestSection}
{#if hard && hard.count >= 3}
<Container class="p-4 w-full">
<div class="flex items-start gap-3 w-full">
<span class="text-2xl">💪</span>
<div class="flex-1 min-w-0">
<p
class="text-gray-100 font-medium text-sm"
>
Room to grow: {hard.section}
</p>
<p
class="text-gray-400 text-xs mt-0.5"
>
{hard.avgGuesses} avg guesses across
{hard.count} games
</p>
</div>
</div>
</Container>
{/if}
{/if}
</div>
</div>
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
@@ -0,0 +1,97 @@
import { db } from '$lib/server/db';
import { verseSubmissions, dailyVerses, user } from '$lib/server/db/schema';
import { eq, asc } from 'drizzle-orm';
import { getBookById } from '$lib/server/bible';
import { isAdmin } from '$lib/server/admin';
import type { PageServerLoad } from './$types';
export type ScheduledVerseRow = {
scheduledDate: string;
reference: string | null;
verseText: string | null;
bookId: string | null;
bookName: string | null;
selectedBookId: string;
selectedChapter: number;
selectedVerse: number;
submittedAt: number; // server UTC millis
submitterEmail: string | null;
submitterName: string | null;
submitterDeleted: boolean;
};
export const load: PageServerLoad = async ({ locals }) => {
// Not authenticated → signal the client to show the sign-in flow
// (same pattern as /progress, /stats).
if (!locals.user) {
return {
rows: [] as ScheduledVerseRow[],
requiresAuth: true,
authorized: false,
user: null,
session: null,
};
}
// Authenticated but wrong account → plain access-denied state. Don't leak
// the existence of privileged data; just render "not authorized".
if (!isAdmin(locals.user.email)) {
return {
rows: [] as ScheduledVerseRow[],
requiresAuth: false,
authorized: false,
user: locals.user,
session: locals.session,
};
}
const joined = await db
.select({
scheduledDate: verseSubmissions.scheduledDate,
selectedBookId: verseSubmissions.selectedBookId,
selectedChapter: verseSubmissions.selectedChapter,
selectedVerse: verseSubmissions.selectedVerse,
submittedAt: verseSubmissions.submittedAt,
dailyDate: dailyVerses.date,
bookId: dailyVerses.bookId,
reference: dailyVerses.reference,
verseText: dailyVerses.verseText,
userId: verseSubmissions.userId,
userEmail: user.email,
userFirstName: user.firstName,
userLastName: user.lastName,
})
.from(verseSubmissions)
// LEFT JOINs so deleted accounts (ON DELETE SET NULL) still render —
// the scheduled verse plays out regardless.
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
.leftJoin(user, eq(verseSubmissions.userId, user.id))
.orderBy(asc(verseSubmissions.scheduledDate));
const rows: ScheduledVerseRow[] = joined.map((r) => {
const book = r.bookId ? getBookById(r.bookId) : undefined;
return {
scheduledDate: r.scheduledDate,
reference: r.reference,
verseText: r.verseText,
bookId: r.bookId,
bookName: book?.name ?? null,
selectedBookId: r.selectedBookId,
selectedChapter: r.selectedChapter,
selectedVerse: r.selectedVerse,
submittedAt: r.submittedAt,
submitterEmail: r.userEmail,
submitterName: [r.userFirstName, r.userLastName].filter(Boolean).join(' ') || null,
// userId is null when the account was deleted (ON DELETE SET NULL)
submitterDeleted: r.userId === null,
};
});
return {
rows,
requiresAuth: false,
authorized: true,
user: locals.user,
session: locals.session,
};
};
+329
View File
@@ -0,0 +1,329 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import AuthModal from '$lib/components/AuthModal.svelte';
import Container from '$lib/components/Container.svelte';
import { bibleBooks } from '$lib/types/bible';
type ScheduledVerseRow = {
scheduledDate: string;
reference: string | null;
verseText: string | null;
bookId: string | null;
bookName: string | null;
selectedBookId: string;
selectedChapter: number;
selectedVerse: number;
submittedAt: number;
submitterEmail: string | null;
submitterName: string | null;
submitterDeleted: boolean;
};
interface PageData {
rows: ScheduledVerseRow[];
requiresAuth: boolean;
authorized: boolean;
user?: any;
session?: any;
}
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let anonymousId = $state('');
let filter = $state<'all' | 'upcoming' | 'past'>('all');
const filters = ['all', 'upcoming', 'past'] as const;
// Dev-only seed button (calls POST /api/dev/seed-submission, which is
// host-gated to localhost:5173 / test.bibdle.com). Hidden in prod.
const isDevHost = $derived(
browser &&
['localhost:5173', 'test.bibdle.com'].includes(window.location.host)
);
let seeding = $state(false);
let seedMessage = $state<{ ok: boolean; text: string } | null>(null);
async function seedSubmission() {
seeding = true;
seedMessage = null;
try {
const res = await fetch('/api/dev/seed-submission', { method: 'POST' });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
seedMessage = {
ok: false,
text: body?.error ?? `Failed (${res.status})`
};
} else {
seedMessage = {
ok: true,
text: `Seeded ${body?.scheduledDate ?? ''} — ${body?.reference ?? ''}`
};
// Reload server load data so the new row appears in the table.
await invalidateAll();
}
} catch (err) {
seedMessage = { ok: false, text: String(err) };
} finally {
seeding = false;
}
}
function getOrCreateAnonymousId(): string {
if (!browser) return '';
const key = 'bibdle-anonymous-id';
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
onMount(() => {
anonymousId = getOrCreateAnonymousId();
});
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}
function formatTimestamp(ms: number): string {
return new Date(ms).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
});
}
const todayUtc = $derived(new Date().toISOString().slice(0, 10));
const filteredRows = $derived.by(() => {
if (filter === 'all') return data.rows;
if (filter === 'upcoming') return data.rows.filter((r) => r.scheduledDate >= todayUtc);
return data.rows.filter((r) => r.scheduledDate < todayUtc);
});
function selectedBookName(bookId: string): string {
return bibleBooks.find((b) => b.id === bookId)?.name ?? bookId;
}
</script>
<svelte:head>
<title>Scheduled Verses | Bibdle</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
>
<div class="max-w-5xl mx-auto">
<div class="text-center mb-6 md:mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-4">
Scheduled Verses
</h1>
<a href="/" class="p-2 px-20 w-full items-center text-gray-300">
&larr; Back to Game
</a>
</div>
{#if data.requiresAuth}
<div class="text-center py-12">
<div
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-blue-200 mb-4">
Authentication Required
</h2>
<p class="text-blue-300 mb-6">
You must be signed in to view this page.
</p>
<div class="flex flex-col gap-3">
<button
onclick={() => (authModalOpen = true)}
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Sign In
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
</div>
{:else if !data.authorized}
<div class="text-center py-12">
<div
class="bg-red-950/50 border border-red-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-red-200 mb-4">Not Authorized</h2>
<p class="text-red-300 mb-6">You do not have access to this page.</p>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
{:else}
<!-- Filter toggle -->
<div class="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div class="flex items-center gap-2 flex-wrap">
<div class="flex gap-1 bg-white/5 rounded-lg p-1 border border-white/10">
{#each filters as f}
<button
onclick={() => (filter = f)}
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors {filter ===
f
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-white/5'}"
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
{/each}
</div>
{#if isDevHost}
<button
onclick={seedSubmission}
disabled={seeding}
class="px-3 py-1.5 rounded-md text-xs font-medium border border-white/10 bg-white/5 text-gray-200 hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{seeding ? 'Seeding…' : '+ Seed test submission'}
</button>
{/if}
</div>
<div class="flex items-center gap-3">
{#if seedMessage}
<span
class="text-xs {seedMessage.ok
? 'text-emerald-400'
: 'text-red-400'}"
>
{seedMessage.text}
</span>
{/if}
<span class="text-xs text-gray-500">
{filteredRows.length} {filteredRows.length === 1 ? 'row' : 'rows'}
</span>
</div>
</div>
{#if filteredRows.length === 0}
<Container class="p-8 w-full text-center">
<p class="text-gray-400">No submissions yet.</p>
</Container>
{:else}
<Container class="p-2 md:p-4 w-full overflow-x-auto">
<table class="w-full text-sm text-left text-gray-200">
<thead
class="text-xs uppercase text-gray-400 border-b border-white/10"
>
<tr>
<th class="px-2 md:px-3 py-2">Scheduled</th>
<th class="px-2 md:px-3 py-2">Reference</th>
<th class="px-2 md:px-3 py-2 hidden md:table-cell">
Window text
</th>
<th class="px-2 md:px-3 py-2">Submitted by</th>
<th class="px-2 md:px-3 py-2 hidden sm:table-cell">
Submitted at
</th>
<th class="px-2 md:px-3 py-2 hidden lg:table-cell">
Selected
</th>
</tr>
</thead>
<tbody>
{#each filteredRows as row (row.scheduledDate)}
<tr
class="border-b border-white/5 hover:bg-white/5 align-top"
>
<td class="px-2 md:px-3 py-2 whitespace-nowrap">
<div class="font-medium text-gray-100">
{formatDate(row.scheduledDate)}
</div>
{#if row.scheduledDate < todayUtc}
<span class="text-[10px] text-gray-500"
>played</span
>
{:else if row.scheduledDate === todayUtc}
<span
class="text-[10px] text-emerald-400"
>today</span
>
{/if}
</td>
<td class="px-2 md:px-3 py-2">
{#if row.reference}
<div class="font-medium">
{row.reference}
</div>
<div class="text-[10px] text-gray-500">
{row.bookName ?? row.bookId}
</div>
{:else}
<span class="text-gray-500"
>— no daily_verse row —</span
>
{/if}
</td>
<td
class="px-2 md:px-3 py-2 hidden md:table-cell max-w-xs"
>
<span class="text-xs text-gray-400 line-clamp-4"
>{row.verseText ?? ''}</span
>
</td>
<td class="px-2 md:px-3 py-2">
{#if row.submitterDeleted}
<span class="text-gray-500 italic"
>(deleted user)</span
>
{:else}
{#if row.submitterName}
<div>{row.submitterName}</div>
{/if}
<div class="text-[11px] text-gray-400">
{row.submitterEmail ?? '—'}
</div>
{/if}
</td>
<td
class="px-2 md:px-3 py-2 hidden sm:table-cell whitespace-nowrap text-xs text-gray-400"
>
{formatTimestamp(row.submittedAt)}
</td>
<td
class="px-2 md:px-3 py-2 hidden lg:table-cell whitespace-nowrap text-xs text-gray-400"
>
{selectedBookName(row.selectedBookId)}
{row.selectedChapter}:{row.selectedVerse}
</td>
</tr>
{/each}
</tbody>
</table>
</Container>
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+513
View File
@@ -0,0 +1,513 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { eq } from "drizzle-orm";
import { testDb as db } from "../src/lib/server/db/test";
import { dailyVerses, verseSubmissions, user } from "../src/lib/server/db/schema";
import {
addDays,
dayDiff,
getCooldownState,
isUniqueConstraintError,
scheduleSubmission,
validateSelection,
COOLDOWN_MS,
type Db
} from "../src/lib/server/verse-submission";
import { bookIdToNumber } from "../src/lib/server/bible";
// ---- helpers --------------------------------------------------------------
const TODAY = "2026-07-01"; // frozen "today" for deterministic scheduling tests
const DAY_MS = 1000 * 60 * 60 * 24;
function uuid() {
return Bun.randomUUIDv7();
}
/** Insert a committed daily_verses row (simulating an existing scheduled verse). */
async function seedDailyVerse(
date: string,
bookId: string,
reference: string,
verseText = "verse text"
) {
await db
.insert(dailyVerses)
.values({
id: uuid(),
date,
bookId,
verseText,
reference,
createdAt: new Date(0)
})
.run();
}
async function seedUser(id: string) {
await db
.insert(user)
.values({
id,
firstName: "Test",
email: `${id}@example.com`,
isPrivate: false
})
.run();
}
async function seedSubmission(
userId: string,
scheduledDate: string,
submittedAt: number,
bookId = "GEN",
chapter = 1,
verse = 3
) {
await db
.insert(verseSubmissions)
.values({
id: uuid(),
userId,
scheduledDate,
selectedBookId: bookId,
selectedChapter: chapter,
selectedVerse: verse,
submittedAt
})
.run();
}
async function clearAll() {
await db.delete(verseSubmissions).run();
await db.delete(dailyVerses).run();
await db.delete(user).run();
}
// ===========================================================================
// Pure date arithmetic
// ===========================================================================
describe("addDays / dayDiff (UTC arithmetic)", () => {
test("addDays moves forward across month/year boundaries", () => {
expect(addDays("2026-01-31", 1)).toBe("2026-02-01");
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
expect(addDays("2026-02-28", 7)).toBe("2026-03-07");
});
test("addDays is negative-safe", () => {
expect(addDays("2026-03-01", -1)).toBe("2026-02-28");
expect(addDays("2026-01-01", -1)).toBe("2025-12-31");
});
test("addDays is its own inverse with dayDiff", () => {
const start = "2026-07-01";
for (const n of [0, 1, 7, 30, 365, -1, -60]) {
const shifted = addDays(start, n);
expect(dayDiff(start, shifted)).toBe(n);
}
});
test("dayDiff handles DST-free UTC whole days exactly", () => {
expect(dayDiff("2026-07-01", "2026-07-02")).toBe(1);
expect(dayDiff("2026-07-01", "2026-06-30")).toBe(-1);
expect(dayDiff("2026-01-01", "2026-12-31")).toBe(364);
});
});
// ===========================================================================
// validateSelection (structural validation, no DB)
// ===========================================================================
describe("validateSelection", () => {
test("accepts a valid book/chapter/verse", () => {
expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 1 })).toBeNull();
expect(validateSelection({ bookId: "JHN", chapter: 3, verse: 16 })).toBeNull();
});
test("rejects unknown bookId", () => {
expect(validateSelection({ bookId: "ZZZ", chapter: 1, verse: 1 })).toBe(
"Unknown bookId"
);
});
test("rejects chapter out of range", () => {
const gen = bookIdToNumber["GEN"];
expect(validateSelection({ bookId: "GEN", chapter: 999, verse: 1 })).toContain(
"chapter out of range"
);
});
test("rejects verse out of range", () => {
expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 9999 })).toContain(
"verse out of range"
);
});
test("rejects non-positive / non-integer chapter & verse", () => {
expect(validateSelection({ bookId: "GEN", chapter: 0, verse: 1 })).toContain(
"positive integer"
);
expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 0 })).toContain(
"positive integer"
);
expect(
validateSelection({ bookId: "GEN", chapter: 1.5, verse: 1 })
).toContain("positive integer");
});
});
// ===========================================================================
// isUniqueConstraintError
// ===========================================================================
describe("isUniqueConstraintError", () => {
test("matches SQLITE_CONSTRAINT_UNIQUE code", () => {
expect(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE" })).toBe(true);
});
test("matches SQLITE_CONSTRAINT code", () => {
expect(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT" })).toBe(true);
});
test("matches by message containing UNIQUE", () => {
expect(
isUniqueConstraintError(
new Error("SQLITE_CONSTRAINT: UNIQUE constraint failed: daily_verses.date")
)
).toBe(true);
});
test("returns false for unrelated errors", () => {
expect(isUniqueConstraintError(new Error("something else"))).toBe(false);
expect(isUniqueConstraintError(null)).toBe(false);
expect(isUniqueConstraintError(undefined)).toBe(false);
});
});
// ===========================================================================
// Cooldown arithmetic (DB)
// ===========================================================================
describe("getCooldownState", () => {
const userId = "user-cooldown";
beforeEach(async () => {
await clearAll();
await seedUser(userId);
});
afterEach(async () => {
await clearAll();
});
test("no submissions → not on cooldown, null end", async () => {
const state = await getCooldownState(db, userId);
expect(state.onCooldown).toBe(false);
expect(state.cooldownEndsAt).toBeNull();
});
test("recent submission → on cooldown, ends at submittedAt + 7d", async () => {
const now = Date.now();
const submittedAt = now - 1000; // 1s ago
await seedSubmission(userId, "2026-08-15", submittedAt);
const state = await getCooldownState(db, userId, now);
expect(state.onCooldown).toBe(true);
expect(state.cooldownEndsAt).toBe(submittedAt + COOLDOWN_MS);
});
test("submission exactly 7 days ago → cooldown just expired (boundary)", async () => {
const now = 1_700_000_000_000;
const submittedAt = now - COOLDOWN_MS; // exactly 7d ago
await seedSubmission(userId, "2026-08-15", submittedAt);
// now == cooldownEndsAt → no longer on cooldown
const state = await getCooldownState(db, userId, now);
expect(state.onCooldown).toBe(false);
});
test("submission 6d23h ago → still on cooldown", async () => {
const now = 1_700_000_000_000;
const submittedAt = now - (COOLDOWN_MS - 1000);
await seedSubmission(userId, "2026-08-15", submittedAt);
const state = await getCooldownState(db, userId, now);
expect(state.onCooldown).toBe(true);
expect(state.cooldownEndsAt).toBe(submittedAt + COOLDOWN_MS);
});
test("uses the most recent of multiple submissions", async () => {
const now = Date.now();
await seedSubmission(userId, "2026-08-15", now - 20 * DAY_MS); // older
await seedSubmission(userId, "2026-08-22", now - 2 * DAY_MS); // recent
const state = await getCooldownState(db, userId, now);
expect(state.onCooldown).toBe(true);
// The recent one (2d ago) drives the cooldown, not the 20d-old one.
expect(state.cooldownEndsAt).toBe(now - 2 * DAY_MS + COOLDOWN_MS);
});
});
// ===========================================================================
// Scheduling validity (DB)
// ===========================================================================
describe("scheduleSubmission — validity rules", () => {
const userId = "user-sched";
const now = Date.now();
beforeEach(async () => {
await clearAll();
await seedUser(userId);
});
afterEach(async () => {
await clearAll();
});
test("empty calendar → schedules tomorrow (server UTC)", async () => {
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
userId,
now,
{ today: TODAY }
);
expect(result.scheduledDate).toBe(addDays(TODAY, 1));
expect(result.bookId).toBe("GEN");
expect(result.reference).toBe("Genesis 1:1-3");
// Both rows written.
const [dv] = await db
.select()
.from(dailyVerses)
.where(eq(dailyVerses.date, result.scheduledDate));
expect(dv).toBeDefined();
expect(dv.bookId).toBe("GEN");
const [vs] = await db
.select()
.from(verseSubmissions)
.where(eq(verseSubmissions.scheduledDate, result.scheduledDate));
expect(vs).toBeDefined();
expect(vs.userId).toBe(userId);
expect(vs.selectedBookId).toBe("GEN");
expect(vs.selectedChapter).toBe(1);
expect(vs.selectedVerse).toBe(3);
expect(vs.submittedAt).toBe(now);
});
test("skips a date whose D-1 neighbor is the same book (no back-to-back)", async () => {
// Committed row at tomorrow with GEN → tomorrow is back-to-blocked for GEN
// (its D-1 = today, but today is empty so no conflict; instead block via
// seeding GEN at day+2, which makes day+3's D-1 a GEN).
const d2 = addDays(TODAY, 2);
await seedDailyVerse(d2, "GEN", "Genesis 1:10-12");
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
userId,
now,
{ today: TODAY }
);
// tomorrow (d1) is empty, d-1=today empty, d+1=d2=GEN → back-to-back → skip.
// d2 is occupied. d3's d-1=d2=GEN → back-to-back → skip. d4 is earliest valid
// (different window ref, so the 60-day repeat rule does not apply).
expect(result.scheduledDate).toBe(addDays(TODAY, 4));
});
test("skips a date whose D+1 neighbor is the same book", async () => {
// Seed GEN at day+3. Then day+2 (empty) has D+1 = GEN → back-to-back.
// day+1: D+1 = day+2 empty, ok; D-1=today empty → valid → schedules day+1.
// To force the D+1 rule, seed GEN at day+1 so day+1 is occupied and
// the next empty candidate (day+2) has D+1=day+3 ... need day+3 to be GEN.
const d1 = addDays(TODAY, 1);
const d3 = addDays(TODAY, 3);
await seedDailyVerse(d1, "EXO", "Exodus 1:1-3"); // occupy day+1 (different book)
await seedDailyVerse(d3, "GEN", "Genesis 1:10-12"); // day+3 = GEN
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
userId,
now,
{ today: TODAY }
);
// day+2 is empty but D+1 = day+3 = GEN → back-to-back → skip.
// day+3 occupied. day+4: D-1=day+3=GEN → back-to-back → skip.
// day+5: D-1=day+4 empty, D+1=day+6 empty → valid (different window ref).
expect(result.scheduledDate).toBe(addDays(TODAY, 5));
});
test("back-to-back with a different book is allowed", async () => {
// Seed EXO at tomorrow. A GEN submission at day+2 has D-1=day+1=EXO (diff) → ok.
await seedDailyVerse(addDays(TODAY, 1), "EXO", "Exodus 1:1-3");
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
userId,
now,
{ today: TODAY }
);
expect(result.scheduledDate).toBe(addDays(TODAY, 2));
});
test("60-day repeat: identical window within ±60d pushes the date out", async () => {
// Seed an identical GEN 1:1-3 window at day+10.
const d10 = addDays(TODAY, 10);
await seedDailyVerse(d10, "GEN", "Genesis 1:1-3");
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 }, // same window: Genesis 1:1-3
userId,
now,
{ today: TODAY }
);
// The earliest empty, non-back-to-back candidate whose distance from d10
// is > 60 days. day+1..day+9: within 60d of d10 (and day+9 D+1=d10=GEN
// back-to-back anyway). day+11: D-1=d10=GEN → back-to-back + within 60.
// ... all dates within [d10-60, d10+60] are repeat-blocked. The first
// valid date is d10+61.
expect(result.scheduledDate).toBe(addDays(d10, 61));
// Sanity: distance is just over 60 days.
expect(Math.abs(dayDiff(d10, result.scheduledDate))).toBeGreaterThan(60);
});
test("different-window repeat at the same book is NOT blocked by rule 4", async () => {
// Seed GEN 1:1-3 at day+1. A GEN 1:4-6 submission shares the book but
// not the window identity, so rule 4 (60-day repeat) does not apply —
// only back-to-back matters.
const d1 = addDays(TODAY, 1);
await seedDailyVerse(d1, "GEN", "Genesis 1:1-3");
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 6 }, // window Genesis 1:4-6
userId,
now,
{ today: TODAY }
);
// day+1 occupied. day+2: D-1=day+1=GEN → back-to-back → skip.
// day+3: D-1=day+2 empty → valid (window differs, so no 60-day block).
expect(result.scheduledDate).toBe(addDays(TODAY, 3));
expect(result.reference).toBe("Genesis 1:4-6");
});
test("fall-forward window (Gen 1:1) schedules and stores Gen 1:1-3", async () => {
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 1 },
userId,
now,
{ today: TODAY }
);
expect(result.reference).toBe("Genesis 1:1-3");
});
});
// ===========================================================================
// Concurrency (DB)
// ===========================================================================
describe("scheduleSubmission — concurrency", () => {
const now = Date.now();
beforeEach(async () => {
await clearAll();
});
afterEach(async () => {
await clearAll();
});
test("two concurrent submissions with the same book land on distinct dates", async () => {
const u1 = "user-conc-1";
const u2 = "user-conc-2";
await seedUser(u1);
await seedUser(u2);
const [r1, r2] = await Promise.all([
scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 3 }, u1, now, {
today: TODAY
}),
scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 4 }, u2, now, {
today: TODAY
})
]);
// Both must succeed and never share a scheduled date.
expect(r1.scheduledDate).not.toBe(r2.scheduledDate);
// And they must not be back-to-back (same book).
const diff = Math.abs(dayDiff(r1.scheduledDate, r2.scheduledDate));
expect(diff).toBeGreaterThan(1);
// Both rows exist in verse_submissions with their own user.
const all = await db.select().from(verseSubmissions).all();
expect(all).toHaveLength(2);
const userIds = all.map((r) => r.userId).sort();
expect(userIds).toEqual([u1, u2].sort());
// And two distinct daily_verses rows.
const dv = await db.select().from(dailyVerses).all();
expect(dv).toHaveLength(2);
expect(new Set(dv.map((r) => r.date)).size).toBe(2);
});
test("retry recovers when a concurrent insert claims the candidate first", async () => {
// Simulate a "lost race" by pre-occupying tomorrow (the first candidate)
// right before the call — the scan already ran against a stale calendar
// only if we insert between scan and insert. Instead, verify the simpler
// guarantee: an existing row on the candidate date is detected on retry
// because loadCalendar is re-read each attempt.
const u1 = "user-conc-race";
await seedUser(u1);
// Seed GEN at every day from tomorrow..tomorrow+3 so the first valid
// empty slot for GEN (respecting back-to-back) is pushed well out. This
// exercises the scan walking past occupied + back-to-back dates.
for (let i = 1; i <= 3; i++) {
await seedDailyVerse(addDays(TODAY, i), "GEN", `Genesis 1:10-12`);
}
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
u1,
now,
{ today: TODAY }
);
// Days 1..3 occupied with GEN (different window ref, so rule 4 is silent).
// day+4: D-1=day+3=GEN → back-to-back → skip.
// day+5: D-1=day+4 empty, D+1=day+6 empty → valid.
expect(result.scheduledDate).toBe(addDays(TODAY, 5));
});
test("two concurrent submissions for different books can be adjacent", async () => {
const u1 = "user-conc-diff-1";
const u2 = "user-conc-diff-2";
await seedUser(u1);
await seedUser(u2);
const [r1, r2] = await Promise.all([
scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 3 }, u1, now, {
today: TODAY
}),
scheduleSubmission(db, { bookId: "EXO", chapter: 1, verse: 3 }, u2, now, {
today: TODAY
})
]);
expect(r1.bookId).toBe("GEN");
expect(r2.bookId).toBe("EXO");
expect(r1.scheduledDate).not.toBe(r2.scheduledDate);
// Different books may legitimately be adjacent (diff == 1) — just assert
// both are distinct future dates.
const diff = Math.abs(dayDiff(r1.scheduledDate, r2.scheduledDate));
expect(diff).toBeGreaterThanOrEqual(1);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, test, expect } from "bun:test";
import {
composeVerseWindow,
formatWindowReference,
getChapterCount,
getVerseCount,
extractVerses,
} from "$lib/server/xml-bible";
import { bookIdToNumber, getBookById } from "$lib/server/bible";
describe("getChapterCount / getVerseCount", () => {
test("Genesis has 50 chapters", () => {
expect(getChapterCount(bookIdToNumber["GEN"])).toBe(50);
});
test("Jude has 1 chapter", () => {
expect(getChapterCount(bookIdToNumber["JUD"])).toBe(1);
});
test("Genesis 1 has 31 verses", () => {
expect(getVerseCount(bookIdToNumber["GEN"], 1)).toBe(31);
});
});
describe("composeVerseWindow — default [v-2, v-1, v]", () => {
test("Genesis 1:3 → window is 1:1-3 (same chapter, hyphen)", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 1, 3);
expect(w).not.toBeNull();
expect(w!.startChapter).toBe(1);
expect(w!.startVerse).toBe(1);
expect(w!.endChapter).toBe(1);
expect(w!.endVerse).toBe(3);
expect(w!.verses).toHaveLength(3);
expect(formatWindowReference(w!.bookName, w!.startChapter, w!.startVerse, w!.endChapter, w!.endVerse))
.toBe("Genesis 1:1-3");
});
test("selected verse is the last in the default window", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 1, 10);
expect(w!.selectedVerse).toBe(10);
// verses[2] should equal Genesis 1:10's text
const direct = extractVerses(bookIdToNumber["GEN"], 1, 10, 1);
expect(w!.verses[2]).toBe(direct[0]);
});
});
describe("composeVerseWindow — fall-forward at book start", () => {
test("Genesis 1:1 → fall forward to [1,2,3]", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 1, 1);
expect(w!.startChapter).toBe(1);
expect(w!.startVerse).toBe(1);
expect(w!.endChapter).toBe(1);
expect(w!.endVerse).toBe(3);
expect(w!.verses).toHaveLength(3);
expect(formatWindowReference(w!.bookName, w!.startChapter, w!.startVerse, w!.endChapter, w!.endVerse))
.toBe("Genesis 1:1-3");
});
test("Genesis 1:2 → fall forward to [2,3,4] (v-2 < 1)", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 1, 2);
expect(w!.startVerse).toBe(2);
expect(w!.endVerse).toBe(4);
expect(w!.verses).toHaveLength(3);
});
test("John 1:1 → fall forward", () => {
const w = composeVerseWindow(bookIdToNumber["JHN"], 1, 1);
expect(w!.bookId).toBe("JHN");
expect(w!.startVerse).toBe(1);
expect(w!.endVerse).toBe(3);
});
});
describe("composeVerseWindow — cross-chapter within a book", () => {
test("Genesis 2:1 → window spans 1:30-2:1 (en-dash, two chapters)", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 2, 1);
expect(w!.startChapter).toBe(1);
expect(w!.endChapter).toBe(2);
// Genesis 1 has 31 verses; selected index for 2:1 = 31 + 1 = 32; window = [30,31,32]
expect(w!.startVerse).toBe(30);
expect(w!.startChapter === 1 && w!.endChapter === 2).toBe(true);
expect(w!.verses).toHaveLength(3);
const ref = formatWindowReference(w!.bookName, w!.startChapter, w!.startVerse, w!.endChapter, w!.endVerse);
expect(ref).toBe("Genesis 1:30\u20132:1");
});
test("window never crosses a book boundary", () => {
// Every book has >= 3 verses, and windows are constrained in-book by construction.
// Spot-check the last verse of Malachi 4 (end of OT) still resolves in-book.
const mal = bookIdToNumber["MAL"];
const lastChapter = getChapterCount(mal);
const lastVerse = getVerseCount(mal, lastChapter);
const w = composeVerseWindow(mal, lastChapter, lastVerse);
expect(w).not.toBeNull();
expect(w!.bookId).toBe("MAL");
expect(w!.endChapter).toBe(lastChapter);
});
});
describe("composeVerseWindow — invalid input", () => {
test("unknown book number returns null", () => {
expect(composeVerseWindow(999, 1, 1)).toBeNull();
});
test("chapter out of range returns null", () => {
expect(composeVerseWindow(bookIdToNumber["GEN"], 999, 1)).toBeNull();
});
test("verse out of range returns null", () => {
expect(composeVerseWindow(bookIdToNumber["GEN"], 1, 9999)).toBeNull();
});
});
describe("formatWindowReference", () => {
test("same chapter, range", () => {
expect(formatWindowReference("Genesis", 1, 1, 1, 3)).toBe("Genesis 1:1-3");
});
test("same chapter, single verse", () => {
expect(formatWindowReference("Jude", 1, 5, 1, 5)).toBe("Jude 1:5");
});
test("cross chapter uses en-dash", () => {
expect(formatWindowReference("Genesis", 1, 31, 2, 1)).toBe("Genesis 1:31\u20132:1");
});
});
describe("composeVerseWindow — every book's opening verse is safe", () => {
// The spec guarantees every book has >=3 verses after its opening, so Gen 1:1-style
// fall-forward must succeed for the first verse of every book.
test("book 1:1 of all 66 books resolves to a 3-verse in-book window", () => {
const ids = Object.keys(bookIdToNumber);
for (const id of ids) {
const num = bookIdToNumber[id];
const w = composeVerseWindow(num, 1, 1);
expect(w, `${getBookById(id)!.name} 1:1 should resolve`).not.toBeNull();
expect(w!.verses).toHaveLength(3);
expect(w!.bookId).toBe(id);
}
});
});
+44
View File
@@ -59,6 +59,50 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done # done
## march 25th
- Added Sign In with Google (OAuth)
- Added Google sign-in button to win screen and footer provider label
- Added rainbow glow effect
## march 24th
- Added achievements system, hint overlay, and progress page polish
## march 23rd
- Extracted CollapsibleTable component and fixed show more behavior
## march 22nd
- Added `/api/send-daily-verse` endpoint for daily Discord verse posting
- Improved guesses collapse timing, win screen CTA, and progress page polish
- Fixed Discord message format (italic date + bold verse)
## march 21st
- Added progress page with activity calendar, book grid, and insights
## march 19th
- Added Discord link and shrunk guesses grid for more than three guesses
- Added MAU section with projection to global stats
- Added survival curve metrics and table minimizing to global stats
## march 15th16th
- Fixed instructions, added color border based on closeness between guess and target
- Added return rate and retention metrics to global stats
- Added WAU history table, fixed retention metric, added new logos and favicon
## march 14th
- Added /global public dashboard with 8 stat cards: completions today, all-time, unique players, players this week, active streaks, avg guesses today, registered users, avg completions per player
- Added traffic & growth analytics section: completions velocity + acceleration, user velocity + acceleration, new players (7d), churned players (7d), net growth (7d)
- Added active streak distribution chart (bar chart by streak length)
- Added 14-day completions trend table with inline bar chart
- Fixed BIBDLE header color in dark mode
## march 12th ## march 12th
- Added about page with social buttons and XML sitemap for SEO - Added about page with social buttons and XML sitemap for SEO
+94
View File
@@ -0,0 +1,94 @@
[{"book":"1 Chronicles","verse":"Of Mahli: Eleazar, who had no sons.\n Of Kish, the son of Kish: Jerahmeel.\n The sons of Mushi: Mahli, Eder, and Jerimoth. These were the sons of the Levites after their fathers houses.\n","citation":"1 Chronicles 24:28-30","date":"2025-12-17"},
{"book":"Deuteronomy","verse":"Yahweh spoke these words to all your assembly on the mountain out of the middle of the fire, of the cloud, and of the thick darkness, with a great voice. He added no more. He wrote them on two stone tablets, and gave them to me.\n When you heard the voice out of the middle of the darkness, while the mountain was burning with fire, you came near to me, even all the heads of your tribes, and your elders;\n and you said, “Behold, Yahweh our God has shown us his glory and his greatness, and we have heard his voice out of the middle of the fire. We have seen today that God does speak with man, and he lives.\n","citation":"Deuteronomy 5:22-24","date":"2025-12-18"},
{"book":"Esther","verse":"Haman also said, “Yes, Esther the queen let no man come in with the king to the banquet that she had prepared but myself; and tomorrow I am also invited by her together with the king.\n Yet all this avails me nothing, so long as I see Mordecai the Jew sitting at the kings gate.”\n Then Zeresh his wife and all his friends said to him, “Let a gallows be made fifty cubits high, and in the morning speak to the king about hanging Mordecai on it. Then go in merrily with the king to the banquet.” This pleased Haman, so he had the gallows made.\n","citation":"Esther 5:12-14","date":"2025-12-19"},
{"book":"Nehemiah","verse":"I discerned, and behold, God had not sent him; but he pronounced this prophecy against me. Tobiah and Sanballat had hired him.\n He hired so that I would be afraid, do so, and sin, and that they might have material for an evil report, that they might reproach me.\n “Remember, my God, Tobiah and Sanballat according to these their works, and also the prophetess Noadiah, and the rest of the prophets, that would have put me in fear.”\n","citation":"Nehemiah 6:12-14","date":"2025-12-20"},
{"book":"Micah","verse":"Therefore he will abandon them until the time that she who is in labor gives birth.\nThen the rest of his brothers will return to the children of Israel.\n He shall stand, and shall shepherd in the strength of Yahweh,\nin the majesty of the name of Yahweh his God:\nand they will live, for then he will be great to the ends of the earth.\n He will be our peace when Assyria invades our land,\nand when he marches through our fortresses,\nthen we will raise against him seven shepherds,\nand eight leaders of men.\n","citation":"Micah 5:3-5","date":"2025-12-21"},
{"book":"Psalms","verse":"Yahweh is your keeper.\nYahweh is your shade on your right hand.\n The sun will not harm you by day,\nnor the moon by night.\n Yahweh will keep you from all evil.\nHe will keep your soul.\n","citation":"Psalms 121:5-7","date":"2025-12-22"},
{"book":"Genesis","verse":"Leah conceived again, and bore a sixth son to Jacob.\n Leah said, “God has endowed me with a good dowry. Now my husband will live with me, because I have borne him six sons.” She named him Zebulun.\n Afterwards, she bore a daughter, and named her Dinah.\n","citation":"Genesis 30:19-21","date":"2025-12-23"},
{"book":"Luke","verse":"And it came to pass in those days that a decree went out from Caesar Augustus that all the world should be registered. This census first took place while Quirinius was governing Syria. So all went to be registered, everyone to his own city.\n\nJoseph also went up from Galilee, out of the city of Nazareth, into Judea, to the city of David, which is called Bethlehem, because he was of the house and lineage of David, to be registered with Mary, his betrothed wife, who was with child. So it was, that while they were there, the days were completed for her to be delivered.","citation":"Luke 2:1-6","date":"2025-12-24"},
{"book":"Luke","verse":"Now there were in the same country shepherds living out in the fields, keeping watch over their flock by night. And behold, an angel of the Lord stood before them, and the glory of the Lord shone around them, and they were greatly afraid. Then the angel said to them, “Do not be afraid, for behold, I bring you good tidings of great joy which will be to all people. For there is born to you this day in the city of David a Savior, who is Christ the Lord. And this will be the sign to you: You will find a Babe wrapped in swaddling cloths, lying in a manger.”","citation":"Luke 2:8-12","date":"2025-12-25"},
{"book":"Jude","verse":"Jude, a bondservant of Jesus Christ, and brother of James, To those who are called, sanctified by God the Father, and preserved in Jesus Christ: Mercy, peace, and love be multiplied to you. Beloved, while I was very diligent to write to you concerning our common salvation, I found it necessary to write to you exhorting you to contend earnestly for the faith which was once for all delivered to the saints.","citation":"Jude 1:1-3","date":"2025-12-26"},
{"book":"Isaiah","verse":"Do not let the son of the foreigner Who has joined himself to the Lord speak, saying, \"The Lord has utterly separated me from His people\"; Nor let the eunuch say, \"Here I am, a dry tree.\" For thus says the Lord: \"To the eunuchs who keep My Sabbaths, and choose what pleases Me, And hold fast My covenant, Even to them I will give in My house And within My walls a place and a name better than that of sons and daughters; I will give them an everlasting name that shall not be cut off.","citation":"Isaiah 56:3-5","date":"2025-12-27"},
{"book":"Matthew","verse":"But Jesus answered and said to them, “I also will ask you one thing, which if you tell Me, I likewise will tell you by what authority I do these things: The baptism of John—where was it from? From heaven or from men?” And they reasoned among themselves, saying, “If we say, From heaven, He will say to us, Why then did you not believe him? But if we say, From men, we fear the multitude, for all count John as a prophet.”","citation":"Matthew 21:24-26","date":"2025-12-28"},
{"book":"2 Chronicles","verse":"Then Hezekiah humbled himself for the pride of his heart, he and the inhabitants of Jerusalem, so that the wrath of the Lord did not come upon them in the days of Hezekiah. Hezekiah had very great riches and honor. And he made himself treasuries for silver, for gold, for precious stones, for spices, for shields, and for all kinds of desirable items; storehouses for the harvest of grain, wine, and oil; and stalls for all kinds of livestock, and folds for flocks.","citation":"2 Chronicles 32:26-28","date":"2025-12-29"},
{"book":"1 Thessalonians","verse":"therefore, brethren, in all our affliction and distress we were comforted concerning you by your faith. For now we live, if you stand fast in the Lord. For what thanks can we render to God for you, for all the joy with which we rejoice for your sake before our God,","citation":"1 Thessalonians 3:7-9","date":"2025-12-30"},
{"book":"Psalms","verse":"The moon and stars to rule by night, For His mercy endures forever. To Him who struck Egypt in their firstborn, For His mercy endures forever; And brought out Israel from among them, For His mercy endures forever;","citation":"Psalms 136:9-11","date":"2025-12-31"},
{"book":"Daniel","verse":"And the male goat is the kingdom of Greece. The large horn that is between its eyes is the first king. As for the broken horn and the four that stood up in its place, four kingdoms shall arise out of that nation, but not with its power. “And in the latter time of their kingdom, When the transgressors have reached their fullness, A king shall arise, Having fierce features, Who understands sinister schemes.","citation":"Daniel 8:21-23","date":"2026-01-01"},
{"book":"Jude","verse":"But you, beloved, remember the words which were spoken before by the apostles of our Lord Jesus Christ: how they told you that there would be mockers in the last time who would walk according to their own ungodly lusts. These are sensual persons, who cause divisions, not having the Spirit.","citation":"Jude 1:17-19","date":"2026-01-02"},
{"book":"Exodus","verse":"And Moses said, “We will go with our young and our old; with our sons and our daughters, with our flocks and our herds we will go, for we must hold a feast to the Lord.” Then he said to them, “The Lord had better be with you when I let you and your little ones go! Beware, for evil is ahead of you. Not so! Go now, you who are men, and serve the Lord, for that is what you desired.” And they were driven out from Pharaohs presence.","citation":"Exodus 10:9-11","date":"2026-01-03"},
{"book":"Deuteronomy","verse":"Gather the people together, men and women and little ones, and the stranger who is within your gates, that they may hear and that they may learn to fear the Lord your God and carefully observe all the words of this law, and that their children, who have not known it, may hear and learn to fear the Lord your God as long as you live in the land which you cross the Jordan to possess.” Then the Lord said to Moses, “Behold, the days approach when you must die; call Joshua, and present yourselves in the tabernacle of meeting, that I may inaugurate him.” So Moses and Joshua went and presented themselves in the tabernacle of meeting.","citation":"Deuteronomy 31:12-14","date":"2026-01-04"},
{"book":"Colossians","verse":"and by Him to reconcile all things to Himself, by Him, whether things on earth or things in heaven, having made peace through the blood of His cross. And you, who once were alienated and enemies in your mind by wicked works, yet now He has reconciled in the body of His flesh through death, to present you holy, and blameless, and above reproach in His sight—","citation":"Colossians 1:20-22","date":"2026-01-05"},
{"book":"2 Thessalonians","verse":"We are bound to thank God always for you, brethren, as it is fitting, because your faith grows exceedingly, and the love of every one of you all abounds toward each other, so that we ourselves boast of you among the churches of God for your patience and faith in all your persecutions and tribulations that you endure, which is manifest evidence of the righteous judgment of God, that you may be counted worthy of the kingdom of God, for which you also suffer;","citation":"2 Thessalonians 1:3-5","date":"2026-01-06"},
{"book":"2 Timothy","verse":"The Lord grant mercy to the household of Onesiphorus, for he often refreshed me, and was not ashamed of my chain; but when he arrived in Rome, he sought me out very zealously and found me. The Lord grant to him that he may find mercy from the Lord in that Day—and you know very well how many ways he ministered to me at Ephesus.","citation":"2 Timothy 1:16-18","date":"2026-01-07"},
{"book":"Micah","verse":"Do not trust in a friend; Do not put your confidence in a companion; Guard the doors of your mouth From her who lies in your bosom. For son dishonors father, Daughter rises against her mother, Daughter-in-law against her mother-in-law; A mans enemies are the men of his own household. Therefore I will look to the Lord; I will wait for the God of my salvation; My God will hear me.","citation":"Micah 7:5-7","date":"2026-01-08"},
{"book":"Amos","verse":"And He said, “Amos, what do you see?” So I said, “A basket of summer fruit.” Then the Lord said to me: “The end has come upon My people Israel; I will not pass by them anymore. And the songs of the temple Shall be wailing in that day,” Says the Lord God— “Many dead bodies everywhere, They shall be thrown out in silence.” Hear this, you who swallow up the needy, And make the poor of the land fail,","citation":"Amos 8:2-4","date":"2026-01-09"},
{"book":"Acts","verse":"But do not yield to them, for more than forty of them lie in wait for him, men who have bound themselves by an oath that they will neither eat nor drink till they have killed him; and now they are ready, waiting for the promise from you.” So the commander let the young man depart, and commanded him, “Tell no one that you have revealed these things to me.” And he called for two centurions, saying, “Prepare two hundred soldiers, seventy horsemen, and two hundred spearmen to go to Caesarea at the third hour of the night;","citation":"Acts 23:21-23","date":"2026-01-10"},
{"book":"Hebrews","verse":"that by two immutable things, in which it is impossible for God to lie, we might have strong consolation, who have fled for refuge to lay hold of the hope set before us. This hope we have as an anchor of the soul, both sure and steadfast, and which enters the Presence behind the veil, where the forerunner has entered for us, even Jesus, having become High Priest forever according to the order of Melchizedek.","citation":"Hebrews 6:18-20","date":"2026-01-11"},
{"book":"Acts","verse":"So when Pauls sisters son heard of their ambush, he went and entered the barracks and told Paul. Then Paul called one of the centurions to him and said, “Take this young man to the commander, for he has something to tell him.” So he took him and brought him to the commander and said, “Paul the prisoner called me to him and asked me to bring this young man to you. He has something to say to you.”","citation":"Acts 23:16-18","date":"2026-01-12"},
{"book":"Habakkuk","verse":"You went forth for the salvation of Your people, For salvation with Your Anointed. You struck the head from the house of the wicked, By laying bare from foundation to neck. You thrust through with his own arrows The head of his villages. They came out like a whirlwind to scatter me; Their rejoicing was like feasting on the poor in secret. You walked through the sea with Your horses, Through the heap of great waters.","citation":"Habakkuk 3:13-15","date":"2026-01-13"},
{"book":"Zephaniah","verse":"In that day you shall not be shamed for any of your deeds In which you transgress against Me; For then I will take away from your midst Those who rejoice in your pride, And you shall no longer be haughty In My holy mountain. I will leave in your midst A meek and humble people, And they shall trust in the name of the Lord. The remnant of Israel shall do no unrighteousness And speak no lies, Nor shall a deceitful tongue be found in their mouth; For they shall feed their flocks and lie down, And no one shall make them afraid.”","citation":"Zephaniah 3:11-13","date":"2026-01-14"},
{"book":"Ezra","verse":"However, in the first year of Cyrus king of Babylon, King Cyrus issued a decree to build this house of God. Also, the gold and silver articles of the house of God, which Nebuchadnezzar had taken from the temple that was in Jerusalem and carried into the temple of Babylon—those King Cyrus took from the temple of Babylon, and they were given to one named Sheshbazzar, whom he had made governor. And he said to him, Take these articles; go, carry them to the temple site that is in Jerusalem, and let the house of God be rebuilt on its former site.","citation":"Ezra 5:13-15","date":"2026-01-15"},
{"book":"Numbers","verse":"They departed from Succoth and camped at Etham, which is on the edge of the wilderness. They moved from Etham and turned back to Pi Hahiroth, which is east of Baal Zephon; and they camped near Migdol. They departed from before Hahiroth and passed through the midst of the sea into the wilderness, went three days journey in the Wilderness of Etham, and camped at Marah.","citation":"Numbers 33:6-8","date":"2026-01-16"},
{"book":"Ruth","verse":"When she saw that she was determined to go with her, she stopped speaking to her. Now the two of them went until they came to Bethlehem. And it happened, when they had come to Bethlehem, that all the city was excited because of them; and the women said, “ Is this Naomi?” But she said to them, “Do not call me Naomi; call me Mara, for the Almighty has dealt very bitterly with me.","citation":"Ruth 1:18-20","date":"2026-01-17"},
{"book":"Matthew","verse":"But as the days of Noah were, so also will the coming of the Son of Man be. For as in the days before the flood, they were eating and drinking, marrying and giving in marriage, until the day that Noah entered the ark, and did not know until the flood came and took them all away, so also will the coming of the Son of Man be.","citation":"Matthew 24:37-39","date":"2026-01-18"},
{"book":"1 Timothy","verse":"Now the purpose of the commandment is love from a pure heart, from a good conscience, and from sincere faith, from which some, having strayed, have turned aside to idle talk, desiring to be teachers of the law, understanding neither what they say nor the things which they affirm.","citation":"1 Timothy 1:5-7","date":"2026-01-19"},
{"book":"1 Peter","verse":"To them it was revealed that, not to themselves, but to us they were ministering the things which now have been reported to you through those who have preached the gospel to you by the Holy Spirit sent from heaven—things which angels desire to look into. Therefore gird up the loins of your mind, be sober, and rest your hope fully upon the grace that is to be brought to you at the revelation of Jesus Christ; as obedient children, not conforming yourselves to the former lusts, as in your ignorance;","citation":"1 Peter 1:12-14","date":"2026-01-20"},
{"book":"Ruth","verse":"Now the two of them went until they came to Bethlehem. And it happened, when they had come to Bethlehem, that all the city was excited because of them; and the women said, “ Is this Naomi?” But she said to them, “Do not call me Naomi; call me Mara, for the Almighty has dealt very bitterly with me. I went out full, and the Lord has brought me home again empty. Why do you call me Naomi, since the Lord has testified against me, and the Almighty has afflicted me?”","citation":"Ruth 1:19-21","date":"2026-01-21"},
{"book":"Ezra","verse":"Then the prophet Haggai and Zechariah the son of Iddo, prophets, prophesied to the Jews who were in Judah and Jerusalem, in the name of the God of Israel, who was over them. So Zerubbabel the son of Shealtiel and Jeshua the son of Jozadak rose up and began to build the house of God which is in Jerusalem; and the prophets of God were with them, helping them. At the same time Tattenai the governor of the region beyond the River and Shethar-Boznai and their companions came to them and spoke thus to them: “Who has commanded you to build this temple and finish this wall?”","citation":"Ezra 5:1-3","date":"2026-01-22"},
{"book":"2 Chronicles","verse":"For now I have chosen and sanctified this house, that My name may be there forever; and My eyes and My heart will be there perpetually. As for you, if you walk before Me as your father David walked, and do according to all that I have commanded you, and if you keep My statutes and My judgments, then I will establish the throne of your kingdom, as I covenanted with David your father, saying, You shall not fail to have a man as ruler in Israel.","citation":"2 Chronicles 7:16-18","date":"2026-01-23"},
{"book":"Joel","verse":"Then the Lord will be zealous for His land, And pity His people. The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations. “But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”","citation":"Joel 2:18-20","date":"2026-01-24"},
{"book":"1 Samuel","verse":"And now this present which your maidservant has brought to my lord, let it be given to the young men who follow my lord. Please forgive the trespass of your maidservant. For the Lord will certainly make for my lord an enduring house, because my lord fights the battles of the Lord, and evil is not found in you throughout your days. Yet a man has risen to pursue you and seek your life, but the life of my lord shall be bound in the bundle of the living with the Lord your God; and the lives of your enemies He shall sling out, as from the pocket of a sling.","citation":"1 Samuel 25:27-29","date":"2026-01-25"},
{"book":"Galatians","verse":"But I saw none of the other apostles except James, the Lords brother. (Now concerning the things which I write to you, indeed, before God, I do not lie.) Afterward I went into the regions of Syria and Cilicia.","citation":"Galatians 1:19-21","date":"2026-01-26"},
{"book":"Lamentations","verse":"How the gold has become dim! How changed the fine gold! The stones of the sanctuary are scattered At the head of every street. The precious sons of Zion, Valuable as fine gold, How they are regarded as clay pots, The work of the hands of the potter! Even the jackals present their breasts To nurse their young; But the daughter of my people is cruel, Like ostriches in the wilderness.","citation":"Lamentations 4:1-3","date":"2026-01-27"},
{"book":"James","verse":"Draw near to God and He will draw near to you. Cleanse your hands, you sinners; and purify your hearts, you double-minded. Lament and mourn and weep! Let your laughter be turned to mourning and your joy to gloom. Humble yourselves in the sight of the Lord, and He will lift you up.","citation":"James 4:8-10","date":"2026-01-28"},
{"book":"2 Kings","verse":"And he called Gehazi and said, “Call this Shunammite woman.” So he called her. And when she came in to him, he said, “Pick up your son.” So she went in, fell at his feet, and bowed to the ground; then she picked up her son and went out. And Elisha returned to Gilgal, and there was a famine in the land. Now the sons of the prophets were sitting before him; and he said to his servant, “Put on the large pot, and boil stew for the sons of the prophets.”","citation":"2 Kings 4:36-38","date":"2026-01-29"},
{"book":"Deuteronomy","verse":"You should know in your heart that as a man chastens his son, so the Lord your God chastens you. “Therefore you shall keep the commandments of the Lord your God, to walk in His ways and to fear Him. For the Lord your God is bringing you into a good land, a land of brooks of water, of fountains and springs, that flow out of valleys and hills;","citation":"Deuteronomy 8:5-7","date":"2026-01-30"},
{"book":"Isaiah","verse":"He burns half of it in the fire; With this half he eats meat; He roasts a roast, and is satisfied. He even warms himself and says, “Ah! I am warm, I have seen the fire.” And the rest of it he makes into a god, His carved image. He falls down before it and worships it, Prays to it and says, “Deliver me, for you are my god!” They do not know nor understand; For He has shut their eyes, so that they cannot see, And their hearts, so that they cannot understand.","citation":"Isaiah 44:16-18","date":"2026-01-31"},
{"book":"3 John","verse":"Beloved, you do faithfully whatever you do for the brethren and for strangers, who have borne witness of your love before the church. If you send them forward on their journey in a manner worthy of God, you will do well, because they went forth for His names sake, taking nothing from the Gentiles.","citation":"3 John 1:5-7","date":"2026-02-01"},
{"book":"1 Thessalonians","verse":"Greet all the brethren with a holy kiss. I charge you by the Lord that this epistle be read to all the holy brethren. The grace of our Lord Jesus Christ be with you. Amen.","citation":"1 Thessalonians 5:26-28","date":"2026-02-02"},
{"book":"Joshua","verse":"Then the manna ceased on the day after they had eaten the produce of the land; and the children of Israel no longer had manna, but they ate the food of the land of Canaan that year. And it came to pass, when Joshua was by Jericho, that he lifted his eyes and looked, and behold, a Man stood opposite him with His sword drawn in His hand. And Joshua went to Him and said to Him, “ Are You for us or for our adversaries?” So He said, “No, but as Commander of the army of the Lord I have now come.” And Joshua fell on his face to the earth and worshiped, and said to Him, “What does my Lord say to His servant?”","citation":"Joshua 5:12-14","date":"2026-02-03"},
{"book":"Song of Solomon","verse":"Your neck is like the tower of David, Built for an armory, On which hang a thousand bucklers, All shields of mighty men. Your two breasts are like two fawns, Twins of a gazelle, Which feed among the lilies. Until the day breaks And the shadows flee away, I will go my way to the mountain of myrrh And to the hill of frankincense.","citation":"Song of Solomon 4:4-6","date":"2026-02-04"},
{"book":"Genesis","verse":"But his father refused and said, “I know, my son, I know. He also shall become a people, and he also shall be great; but truly his younger brother shall be greater than he, and his descendants shall become a multitude of nations.” So he blessed them that day, saying, “By you Israel will bless, saying, May God make you as Ephraim and as Manasseh! ” And thus he set Ephraim before Manasseh. Then Israel said to Joseph, “Behold, I am dying, but God will be with you and bring you back to the land of your fathers.","citation":"Genesis 48:19-21","date":"2026-02-05"},
{"book":"2 Peter","verse":"whereas angels, who are greater in power and might, do not bring a reviling accusation against them before the Lord. But these, like natural brute beasts made to be caught and destroyed, speak evil of the things they do not understand, and will utterly perish in their own corruption, and will receive the wages of unrighteousness, as those who count it pleasure to carouse in the daytime. They are spots and blemishes, carousing in their own deceptions while they feast with you,","citation":"2 Peter 2:11-13","date":"2026-02-06"},
{"book":"2 Chronicles","verse":"Now after the death of Jehoiada the leaders of Judah came and bowed down to the king. And the king listened to them. Therefore they left the house of the Lord God of their fathers, and served wooden images and idols; and wrath came upon Judah and Jerusalem because of their trespass. Yet He sent prophets to them, to bring them back to the Lord; and they testified against them, but they would not listen.","citation":"2 Chronicles 24:17-19","date":"2026-02-07"},
{"book":"Psalms","verse":"Man goes out to his work And to his labor until the evening. O Lord, how manifold are Your works! In wisdom You have made them all. The earth is full of Your possessions— This great and wide sea, In which are innumerable teeming things, Living things both small and great.","citation":"Psalms 104:23-25","date":"2026-02-08"},
{"book":"1 Kings","verse":"Thus Zimri destroyed all the household of Baasha, according to the word of the Lord, which He spoke against Baasha by Jehu the prophet, for all the sins of Baasha and the sins of Elah his son, by which they had sinned and by which they had made Israel sin, in provoking the Lord God of Israel to anger with their idols. Now the rest of the acts of Elah, and all that he did, are they not written in the book of the chronicles of the kings of Israel?","citation":"1 Kings 16:12-14","date":"2026-02-09"},
{"book":"Psalms","verse":"Blessed is the man Who walks not in the counsel of the ungodly, Nor stands in the path of sinners, Nor sits in the seat of the scornful; But his delight is in the law of the Lord, And in His law he meditates day and night. He shall be like a tree Planted by the rivers of water, That brings forth its fruit in its season, Whose leaf also shall not wither; And whatever he does shall prosper.","citation":"Psalms 1:1-3","date":"2026-02-10"},
{"book":"Romans","verse":"And not only that, but we also rejoice in God through our Lord Jesus Christ, through whom we have now received the reconciliation. Therefore, just as through one man sin entered the world, and death through sin, and thus death spread to all men, because all sinned— (For until the law sin was in the world, but sin is not imputed when there is no law.","citation":"Romans 5:11-13","date":"2026-02-11"},
{"book":"1 Peter","verse":"For in this manner, in former times, the holy women who trusted in God also adorned themselves, being submissive to their own husbands, as Sarah obeyed Abraham, calling him lord, whose daughters you are if you do good and are not afraid with any terror. Husbands, likewise, dwell with them with understanding, giving honor to the wife, as to the weaker vessel, and as being heirs together of the grace of life, that your prayers may not be hindered.","citation":"1 Peter 3:5-7","date":"2026-02-12"},
{"book":"Micah","verse":"Will the Lord be pleased with thousands of rams, Ten thousand rivers of oil? Shall I give my firstborn for my transgression, The fruit of my body for the sin of my soul? He has shown you, O man, what is good; And what does the Lord require of you But to do justly, To love mercy, And to walk humbly with your God? The Lords voice cries to the city— Wisdom shall see Your name: “Hear the rod! Who has appointed it?","citation":"Micah 6:7-9","date":"2026-02-13"},
{"book":"Micah","verse":"For all people walk each in the name of his god, But we will walk in the name of the Lord our God Forever and ever. “In that day,” says the Lord, “I will assemble the lame, I will gather the outcast And those whom I have afflicted; I will make the lame a remnant, And the outcast a strong nation; So the Lord will reign over them in Mount Zion From now on, even forever.","citation":"Micah 4:5-7","date":"2026-02-14"},
{"book":"1 John","verse":"And whatever we ask we receive from Him, because we keep His commandments and do those things that are pleasing in His sight. And this is His commandment: that we should believe on the name of His Son Jesus Christ and love one another, as He gave us commandment. Now he who keeps His commandments abides in Him, and He in him. And by this we know that He abides in us, by the Spirit whom He has given us.","citation":"1 John 3:22-24","date":"2026-02-15"},
{"book":"Joel","verse":"Tell your children about it, Let your children tell their children, And their children another generation. What the chewing locust left, the swarming locust has eaten; What the swarming locust left, the crawling locust has eaten; And what the crawling locust left, the consuming locust has eaten. Awake, you drunkards, and weep; And wail, all you drinkers of wine, Because of the new wine, For it has been cut off from your mouth.","citation":"Joel 1:3-5","date":"2026-02-16"},
{"book":"Isaiah","verse":"The Syrians before and the Philistines behind; And they shall devour Israel with an open mouth. For all this His anger is not turned away, But His hand is stretched out still. For the people do not turn to Him who strikes them, Nor do they seek the Lord of hosts. Therefore the Lord will cut off head and tail from Israel, Palm branch and bulrush in one day.","citation":"Isaiah 9:12-14","date":"2026-02-17"},
{"book":"1 Thessalonians","verse":"Abstain from every form of evil. Now may the God of peace Himself sanctify you completely; and may your whole spirit, soul, and body be preserved blameless at the coming of our Lord Jesus Christ. He who calls you is faithful, who also will do it.","citation":"1 Thessalonians 5:22-24","date":"2026-02-18"},
{"book":"Jonah","verse":"Then the mariners were afraid; and every man cried out to his god, and threw the cargo that was in the ship into the sea, to lighten the load. But Jonah had gone down into the lowest parts of the ship, had lain down, and was fast asleep. So the captain came to him, and said to him, “What do you mean, sleeper? Arise, call on your God; perhaps your God will consider us, so that we may not perish.” And they said to one another, “Come, let us cast lots, that we may know for whose cause this trouble has come upon us.” So they cast lots, and the lot fell on Jonah.","citation":"Jonah 1:5-7","date":"2026-02-19"},
{"book":"Judges","verse":"And the Angel of the Lord appeared to the woman and said to her, “Indeed now, you are barren and have borne no children, but you shall conceive and bear a son. Now therefore, please be careful not to drink wine or similar drink, and not to eat anything unclean. For behold, you shall conceive and bear a son. And no razor shall come upon his head, for the child shall be a Nazirite to God from the womb; and he shall begin to deliver Israel out of the hand of the Philistines.”","citation":"Judges 13:3-5","date":"2026-02-20"},
{"book":"1 Thessalonians","verse":"For if we believe that Jesus died and rose again, even so God will bring with Him those who sleep in Jesus. For this we say to you by the word of the Lord, that we who are alive and remain until the coming of the Lord will by no means precede those who are asleep. For the Lord Himself will descend from heaven with a shout, with the voice of an archangel, and with the trumpet of God. And the dead in Christ will rise first.","citation":"1 Thessalonians 4:14-16","date":"2026-02-21"},
{"book":"Daniel","verse":"And suddenly, one having the likeness of the sons of men touched my lips; then I opened my mouth and spoke, saying to him who stood before me, “My lord, because of the vision my sorrows have overwhelmed me, and I have retained no strength. For how can this servant of my lord talk with you, my lord? As for me, no strength remains in me now, nor is any breath left in me.” Then again, the one having the likeness of a man touched me and strengthened me.","citation":"Daniel 10:16-18","date":"2026-02-22"},
{"book":"Exodus","verse":"Then the daughter of Pharaoh came down to bathe at the river. And her maidens walked along the riverside; and when she saw the ark among the reeds, she sent her maid to get it. And when she opened it, she saw the child, and behold, the baby wept. So she had compassion on him, and said, “This is one of the Hebrews children.” Then his sister said to Pharaohs daughter, “Shall I go and call a nurse for you from the Hebrew women, that she may nurse the child for you?”","citation":"Exodus 2:5-7","date":"2026-02-23"},
{"book":"Philippians","verse":"and not in any way terrified by your adversaries, which is to them a proof of perdition, but to you of salvation, and that from God. For to you it has been granted on behalf of Christ, not only to believe in Him, but also to suffer for His sake, having the same conflict which you saw in me and now hear is in me.","citation":"Philippians 1:28-30","date":"2026-02-24"},
{"book":"Hosea","verse":"As they called them, So they went from them; They sacrificed to the Baals, And burned incense to carved images. “I taught Ephraim to walk, Taking them by their arms; But they did not know that I healed them. I drew them with gentle cords, With bands of love, And I was to them as those who take the yoke from their neck. I stooped and fed them.","citation":"Hosea 11:2-4","date":"2026-02-25"},
{"book":"Habakkuk","verse":"You are filled with shame instead of glory. You also—drink! And be exposed as uncircumcised! The cup of the Lords right hand will be turned against you, And utter shame will be on your glory. For the violence done to Lebanon will cover you, And the plunder of beasts which made them afraid, Because of mens blood And the violence of the land and the city, And of all who dwell in it. “What profit is the image, that its maker should carve it, The molded image, a teacher of lies, That the maker of its mold should trust in it, To make mute idols?","citation":"Habakkuk 2:16-18","date":"2026-02-26"},
{"book":"Genesis","verse":"I am the God of Bethel, where you anointed the pillar and where you made a vow to Me. Now arise, get out of this land, and return to the land of your family. ” Then Rachel and Leah answered and said to him, “Is there still any portion or inheritance for us in our fathers house? Are we not considered strangers by him? For he has sold us, and also completely consumed our money.","citation":"Genesis 31:13-15","date":"2026-02-27"},
{"book":"Galatians","verse":"to whom we did not yield submission even for an hour, that the truth of the gospel might continue with you. But from those who seemed to be something—whatever they were, it makes no difference to me; God shows personal favoritism to no man—for those who seemed to be something added nothing to me. But on the contrary, when they saw that the gospel for the uncircumcised had been committed to me, as the gospel for the circumcised was to Peter","citation":"Galatians 2:5-7","date":"2026-02-28"},
{"book":"Revelation","verse":"Now when the dragon saw that he had been cast to the earth, he persecuted the woman who gave birth to the male Child. But the woman was given two wings of a great eagle, that she might fly into the wilderness to her place, where she is nourished for a time and times and half a time, from the presence of the serpent. So the serpent spewed water out of his mouth like a flood after the woman, that he might cause her to be carried away by the flood.","citation":"Revelation 12:13-15","date":"2026-03-01"},
{"book":"Zephaniah","verse":"“I will utterly consume everything From the face of the land,” Says the Lord; “I will consume man and beast; I will consume the birds of the heavens, The fish of the sea, And the stumbling blocks along with the wicked. I will cut off man from the face of the land,” Says the Lord. “I will stretch out My hand against Judah, And against all the inhabitants of Jerusalem. I will cut off every trace of Baal from this place, The names of the idolatrous priests with the pagan priests—","citation":"Zephaniah 1:2-4","date":"2026-03-02"},
{"book":"Malachi","verse":"“But you profane it, In that you say, The table of the Lord is defiled; And its fruit, its food, is contemptible. You also say, Oh, what a weariness! And you sneer at it,” Says the Lord of hosts. “And you bring the stolen, the lame, and the sick; Thus you bring an offering! Should I accept this from your hand?” Says the Lord. “But cursed be the deceiver Who has in his flock a male, And takes a vow, But sacrifices to the Lord what is blemished— For I am a great King,” Says the Lord of hosts, “And My name is to be feared among the nations.","citation":"Malachi 1:12-14","date":"2026-03-03"},
{"book":"Job","verse":"Why does your heart carry you away, And what do your eyes wink at, That you turn your spirit against God, And let such words go out of your mouth? “What is man, that he could be pure? And he who is born of a woman, that he could be righteous?","citation":"Job 15:12-14","date":"2026-03-04"},
{"book":"Acts","verse":"Also, many of those who had practiced magic brought their books together and burned them in the sight of all. And they counted up the value of them, and it totaled fifty thousand pieces of silver. So the word of the Lord grew mightily and prevailed. When these things were accomplished, Paul purposed in the Spirit, when he had passed through Macedonia and Achaia, to go to Jerusalem, saying, “After I have been there, I must also see Rome.”","citation":"Acts 19:19-21","date":"2026-03-05"},
{"book":"2 Peter","verse":"For we did not follow cunningly devised fables when we made known to you the power and coming of our Lord Jesus Christ, but were eyewitnesses of His majesty. For He received from God the Father honor and glory when such a voice came to Him from the Excellent Glory: “This is My beloved Son, in whom I am well pleased.” And we heard this voice which came from heaven when we were with Him on the holy mountain.","citation":"2 Peter 1:16-18","date":"2026-03-06"},
{"book":"James","verse":"If any of you lacks wisdom, let him ask of God, who gives to all liberally and without reproach, and it will be given to him. But let him ask in faith, with no doubting, for he who doubts is like a wave of the sea driven and tossed by the wind. For let not that man suppose that he will receive anything from the Lord;","citation":"James 1:5-7","date":"2026-03-07"},
{"book":"1 Samuel","verse":"And watch: if it goes up the road to its own territory, to Beth Shemesh, then He has done us this great evil. But if not, then we shall know that it is not His hand that struck us—it happened to us by chance.” Then the men did so; they took two milk cows and hitched them to the cart, and shut up their calves at home. And they set the ark of the Lord on the cart, and the chest with the gold rats and the images of their tumors.","citation":"1 Samuel 6:9-11","date":"2026-03-08"},
{"book":"2 Corinthians","verse":"For our boasting is this: the testimony of our conscience that we conducted ourselves in the world in simplicity and godly sincerity, not with fleshly wisdom but by the grace of God, and more abundantly toward you. For we are not writing any other things to you than what you read or understand. Now I trust you will understand, even to the end (as also you have understood us in part), that we are your boast as you also are ours, in the day of the Lord Jesus.","citation":"2 Corinthians 1:12-14","date":"2026-03-09"},
{"book":"1 Kings","verse":"Benaiah the son of Jehoiada answered the king and said, “Amen! May the Lord God of my lord the king say so too. As the Lord has been with my lord the king, even so may He be with Solomon, and make his throne greater than the throne of my lord King David.” So Zadok the priest, Nathan the prophet, Benaiah the son of Jehoiada, the Cherethites, and the Pelethites went down and had Solomon ride on King Davids mule, and took him to Gihon.","citation":"1 Kings 1:36-38","date":"2026-03-10"},
{"book":"Proverbs","verse":"So that your trust may be in the Lord; I have instructed you today, even you. Have I not written to you excellent things Of counsels and knowledge, That I may make you know the certainty of the words of truth, That you may answer words of truth To those who send to you?","citation":"Proverbs 22:19-21","date":"2026-03-11"},
{"book":"2 Samuel","verse":"Then Hadadezer sent and brought out the Syrians who were beyond the River, and they came to Helam. And Shobach the commander of Hadadezers army went before them. When it was told David, he gathered all Israel, crossed over the Jordan, and came to Helam. And the Syrians set themselves in battle array against David and fought with him. Then the Syrians fled before Israel; and David killed seven hundred charioteers and forty thousand horsemen of the Syrians, and struck Shobach the commander of their army, who died there.","citation":"2 Samuel 10:16-18","date":"2026-03-12"},
{"book":"Amos","verse":"“I overthrew some of you, As God overthrew Sodom and Gomorrah, And you were like a firebrand plucked from the burning; Yet you have not returned to Me,” Says the Lord. “Therefore thus will I do to you, O Israel; Because I will do this to you, Prepare to meet your God, O Israel!” For behold, He who forms mountains, And creates the wind, Who declares to man what his thought is, And makes the morning darkness, Who treads the high places of the earth— The Lord God of hosts is His name.","citation":"Amos 4:11-13","date":"2026-03-13"},
{"book":"Hosea","verse":"“Therefore, behold, I will hedge up your way with thorns, And wall her in, So that she cannot find her paths. She will chase her lovers, But not overtake them; Yes, she will seek them, but not find them. Then she will say, I will go and return to my first husband, For then it was better for me than now. For she did not know That I gave her grain, new wine, and oil, And multiplied her silver and gold— Which they prepared for Baal.","citation":"Hosea 2:6-8","date":"2026-03-14"},
{"book":"Jeremiah","verse":"Behold, I will bring fear upon you,” Says the Lord God of hosts, “From all those who are around you; You shall be driven out, everyone headlong, And no one will gather those who wander off. But afterward I will bring back The captives of the people of Ammon,” says the Lord. Against Edom. Thus says the Lord of hosts: “ Is wisdom no more in Teman? Has counsel perished from the prudent? Has their wisdom vanished?","citation":"Jeremiah 49:5-7","date":"2026-03-15"},
{"book":"Genesis","verse":"Now Jacob went out from Beersheba and went toward Haran. So he came to a certain place and stayed there all night, because the sun had set. And he took one of the stones of that place and put it at his head, and he lay down in that place to sleep. Then he dreamed, and behold, a ladder was set up on the earth, and its top reached to heaven; and there the angels of God were ascending and descending on it.","citation":"Genesis 28:10-12","date":"2026-03-16"},
{"book":"1 Kings","verse":"In the twenty-seventh year of Asa king of Judah, Zimri had reigned in Tirzah seven days. And the people were encamped against Gibbethon, which belonged to the Philistines. Now the people who were encamped heard it said, “Zimri has conspired and also has killed the king.” So all Israel made Omri, the commander of the army, king over Israel that day in the camp. Then Omri and all Israel with him went up from Gibbethon, and they besieged Tirzah.","citation":"1 Kings 16:15-17","date":"2026-03-17"},
{"book":"1 John","verse":"And He Himself is the propitiation for our sins, and not for ours only but also for the whole world. Now by this we know that we know Him, if we keep His commandments. He who says, “I know Him,” and does not keep His commandments, is a liar, and the truth is not in him.","citation":"1 John 2:2-4","date":"2026-03-18"},
{"book":"Esther","verse":"And King Ahasuerus imposed tribute on the land and on the islands of the sea. Now all the acts of his power and his might, and the account of the greatness of Mordecai, to which the king advanced him, are they not written in the book of the chronicles of the kings of Media and Persia? For Mordecai the Jew was second to King Ahasuerus, and was great among the Jews and well received by the multitude of his brethren, seeking the good of his people and speaking peace to all his countrymen.","citation":"Esther 10:1-3","date":"2026-03-19"},
{"book":"1 Corinthians","verse":"When I was a child, I spoke as a child, I understood as a child, I thought as a child; but when I became a man, I put away childish things. For now we see in a mirror, dimly, but then face to face. Now I know in part, but then I shall know just as I also am known. And now abide faith, hope, love, these three; but the greatest of these is love.","citation":"1 Corinthians 13:11-13","date":"2026-03-20"}]