Compare commits
3 Commits
99552c57ad
..
cards
| Author | SHA1 | Date | |
|---|---|---|---|
| a372db9b2c | |||
| 1c2f214963 | |||
| f7efe6738d |
@@ -0,0 +1,165 @@
|
||||
# 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.
|
||||
@@ -1,177 +1,38 @@
|
||||
# Bibdle
|
||||
# sv
|
||||
|
||||
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.
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
Live at [bibdle.com](https://bibdle.com).
|
||||
## Creating a project
|
||||
|
||||
## Tech Stack
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
- **Framework**: SvelteKit 2 with Svelte 5 (runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **Database**: SQLite (`bun:sqlite`) with Drizzle ORM
|
||||
- **Auth**: Session-based — email/password (argon2id via `Bun.password`), plus Apple and Google OAuth
|
||||
- **Bible text**: Local NKJV XML (`EnglishNKJBible.xml`), parsed with `fast-xml-parser`; a Greek 1904 and Swedish 2000 translation are also bundled for alternate modes
|
||||
- **ML** (currently disabled): `@xenova/transformers` verse embeddings for a similarity search route
|
||||
- **Deployment**: `@sveltejs/adapter-node`, run under Bun, managed by a systemd service (see `bibdle.service`)
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
bunx sv create
|
||||
|
||||
## Getting Started
|
||||
# create a new project in my-app
|
||||
bunx sv create my-app
|
||||
```
|
||||
|
||||
```bash
|
||||
bun install
|
||||
## Developing
|
||||
|
||||
# Start the dev server (Vite, Bun runtime)
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
bun run dev
|
||||
|
||||
# Type checking
|
||||
bun run check
|
||||
# or start the server and open the app in a new browser tab
|
||||
bun run dev -- --open
|
||||
```
|
||||
|
||||
# Tests (Bun test)
|
||||
bun test
|
||||
bun test tests/timezone-handling.test.ts # single file
|
||||
bun test --watch
|
||||
## Building
|
||||
|
||||
# Production build & preview
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
bun run preview
|
||||
```
|
||||
|
||||
### Database
|
||||
You can preview the production build with `bun run preview`.
|
||||
|
||||
```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.
|
||||
|
||||
Sessions expire after 30 days and auto-renew when fewer than 15 days remain.
|
||||
|
||||
### Bible Data (`src/lib/types/bible.ts`)
|
||||
|
||||
The `bibleBooks` array lists all 66 books with metadata:
|
||||
|
||||
- `testament`: `old` | `new`
|
||||
- `section`: `Law`, `History`, `Wisdom`, `Major Prophets`, `Minor Prophets`, `Gospels`, `Pauline Epistles`, `General Epistles`, `Apocalyptic`
|
||||
- `order` (1–66, used for adjacency detection)
|
||||
|
||||
### Game Logic
|
||||
|
||||
- `src/lib/utils/game.ts` — `evaluateGuess()` compares a guess to the target book and returns `testamentMatch`, `sectionMatch`, `adjacent`, and `firstLetterMatch` flags. `getGrade()` maps guess count to a letter grade (S+ down to C). Includes a special-case so that numbered Epistles (e.g. "1 John") match on first letter against any other numbered Epistle.
|
||||
- `src/lib/stores/game-persistence.svelte.ts` — reactive store that keeps guesses and per-day flags in sync with `localStorage`, keyed by date (`bibdle-guesses-${date}`). Resolves the player identity (logged-in user ID, or a locally generated anonymous UUID) and restores state.
|
||||
- `src/lib/utils/share.ts` — generates the share grid and text. Hint emojis: ✅ exact · 🟩 section · 🟧 testament · ‼️ adjacent · 🟥 no match.
|
||||
|
||||
### Daily Verse System
|
||||
|
||||
`src/lib/server/daily-verse.ts` → `getVerseForDate(date)`: returns the cached verse for a date if present, otherwise fetches a random verse from the local XML Bible and stores it permanently. The XML Bible is read and parsed in `src/lib/server/xml-bible.ts`; `src/lib/server/bible-api.ts` wraps it to produce a verse with a validated `bookId`, `reference`, and `verseText`.
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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. |
|
||||
|
||||
### 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/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.
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
|
||||
|
||||
<a
|
||||
href="mailto:george@snail.city"
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
let copyTracked = $state(false);
|
||||
let showSnippetOption = $state(false);
|
||||
let includeSnippet = $state(false);
|
||||
let showProgressDropdown = $state(false);
|
||||
|
||||
let effectiveShareText = $derived(
|
||||
includeSnippet
|
||||
@@ -334,32 +333,17 @@
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="signin-prompt">
|
||||
<div class="rainbow-glow w-full">
|
||||
<a
|
||||
href="/progress"
|
||||
class="progress-btn w-full"
|
||||
data-umami-event="See your progress (logged in)"
|
||||
class="flex flex-col items-center justify-center gap-1 w-full p-4 mb-2 bg-white dark:bg-gray-900 border-2 border-black/40 dark:border-white/40 rounded-2xl shadow-sm text-gray-800 dark:text-gray-100 text-base font-semibold no-underline transition-transform duration-100 hover:-translate-y-px active:scale-[0.98]"
|
||||
>
|
||||
<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>
|
||||
📈 See your progress
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
@@ -414,52 +398,8 @@
|
||||
Sign in with Google
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
|
||||
<style>
|
||||
@@ -740,59 +680,45 @@
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
/* ── Apple Sign In prompt ── */
|
||||
.rainbow-glow {
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.progress-btn:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 8px 8px 0 0 #000;
|
||||
.rainbow-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
border-radius: 1.25rem;
|
||||
background: conic-gradient(
|
||||
from var(--angle, 0deg),
|
||||
#ff0080,
|
||||
#ff8c00,
|
||||
#ffd700,
|
||||
#00ff88,
|
||||
#00cfff,
|
||||
#a855f7,
|
||||
#ff0080
|
||||
);
|
||||
animation: rainbow-rotate 6s linear infinite;
|
||||
filter: blur(8px);
|
||||
opacity: 0.75;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.progress-btn:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 2px 2px 0 0 #000;
|
||||
@property --angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
.progress-chev {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
transition: transform 200ms ease;
|
||||
@keyframes rainbow-rotate {
|
||||
0% {
|
||||
--angle: 0deg;
|
||||
}
|
||||
|
||||
.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;
|
||||
100% {
|
||||
--angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,59 +730,6 @@
|
||||
/*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 {
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
interface Card {
|
||||
front: string;
|
||||
back: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cards: Card[];
|
||||
fanDelay?: number;
|
||||
}
|
||||
|
||||
let { cards, fanDelay = 2500 }: Props = $props();
|
||||
|
||||
// Extract 4 cards (or fewer if not enough provided)
|
||||
let displayCards = $derived(cards.slice(0, 4));
|
||||
let totalCards = $derived(displayCards.length);
|
||||
|
||||
let fanned = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fanned = true;
|
||||
}, fanDelay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-64 w-96">
|
||||
<!-- Cards start piled on left, fan out to right -->
|
||||
{#each displayCards as card, i (i)}
|
||||
{@const fanOffset = (totalCards - 1 - i) * 75}
|
||||
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center transition-all duration-700 ease-out"
|
||||
style:transform={fanned
|
||||
? `translateX(${fanOffset-100}px) rotate(${8 + i * (-16 / (totalCards - 1))}deg)`
|
||||
: "translateX(-100px) rotate(-8deg)"}
|
||||
style:z-index={totalCards - i}
|
||||
style:transition-delay={fanned ? `${i * 100}ms` : "0ms"}
|
||||
>
|
||||
<img
|
||||
src={card.front}
|
||||
alt="Card {i + 1}"
|
||||
class="max-h-64 w-auto object-contain drop-shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
|
||||
interface Props {
|
||||
front: string;
|
||||
back: string;
|
||||
}
|
||||
|
||||
let { front, back }: Props = $props();
|
||||
|
||||
let fanned = $state(false);
|
||||
|
||||
const cardDeck: Attachment<HTMLDivElement> = (node) => {
|
||||
const check = () => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const cardCenter = rect.top + rect.height / 2;
|
||||
fanned = cardCenter <= window.innerHeight / 2;
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", check, { passive: true });
|
||||
check();
|
||||
|
||||
return () => window.removeEventListener("scroll", check);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
{@attach cardDeck}
|
||||
class="relative h-64 w-48"
|
||||
role="img"
|
||||
aria-label="Card deck"
|
||||
>
|
||||
<!-- Back card -->
|
||||
<img
|
||||
src={back}
|
||||
alt="Back"
|
||||
class="absolute inset-0 max-h-64 w-full object-contain drop-shadow-md transition-all duration-500 ease-in-out"
|
||||
style:transform={fanned ? "translateX(90px) rotate(4deg)" : "rotate(-2deg)"}
|
||||
style:z-index="1"
|
||||
/>
|
||||
<!-- Front card -->
|
||||
<img
|
||||
src={front}
|
||||
alt="Front"
|
||||
class="absolute inset-0 max-h-64 w-full object-contain drop-shadow-md transition-all duration-500 ease-in-out"
|
||||
style:transform={fanned ? "translateX(-90px) rotate(-4deg)" : "rotate(2deg)"}
|
||||
style:z-index="2"
|
||||
/>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
title="Bibdle RSS Feed"
|
||||
href="/feed.xml"
|
||||
/>
|
||||
<meta name="description" content="A Wordle-inspired daily Bible game" />
|
||||
<meta name="description" content="A daily Bible game" />
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
|
||||
@@ -452,6 +452,26 @@
|
||||
<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">
|
||||
<a
|
||||
href="https://discord.gg/yWQXbGK8SD"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center justify-center gap-2 w-full px-5 py-2.5 bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold rounded-lg shadow-md transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 127.14 96.36"
|
||||
class="w-5 h-5 fill-white"
|
||||
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>
|
||||
Join the BIBDLE Discord!
|
||||
</a>
|
||||
</div>
|
||||
<div class="animate-fade-in-up animate-delay-800">
|
||||
<Credits />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import FrontBack from "$lib/components/cards/FrontBack.svelte";
|
||||
import CardFan from "$lib/components/cards/CardFan.svelte";
|
||||
|
||||
const sampleCards = [
|
||||
{ front: "/cards/1_Corinthians_13_front.png", back: "/cards/1_Corinthians_13_back.png" },
|
||||
{ front: "/cards/Esther_4_front.png", back: "/cards/Esther_4_back.png" },
|
||||
{ front: "/cards/Psalms_front.png", back: "/cards/Psalms_back.png" },
|
||||
{ front: "/cards/Revelation_12_13_15_front.png", back: "/cards/Revelation_12_13_15_back.png" }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>BIBDLE Cards</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="text-center pb-96">Collectible Bible Verse Trading Cards</div>
|
||||
<div class="min-h-dvh py-10 px-4 overflow-x-hidden">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
|
||||
<h2 class="text-xl font-semibold mb-6">Card Fan Demo</h2>
|
||||
<p class="text-gray-400 mb-4">Cards will fan out after a few seconds...</p>
|
||||
<div class="flex justify-center mb-96">
|
||||
<CardFan cards={sampleCards} />
|
||||
</div>
|
||||
<div class="flex justify-center mb-96">
|
||||
<FrontBack
|
||||
front="/cards/Esther_4_front.png"
|
||||
back="/cards/Esther_4_back.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { SvelteDate } from "svelte/reactivity";
|
||||
import { onMount } from "svelte";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
@@ -98,6 +99,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isRecent(dateStr: string | null): boolean {
|
||||
if (!dateStr || !browser) return false;
|
||||
const sevenDaysAgo = new SvelteDate();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
const achievedDate = new Date(dateStr + "T00:00:00Z");
|
||||
return achievedDate >= sevenDaysAgo;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T00:00:00Z");
|
||||
return d.toLocaleDateString("en-US", {
|
||||
@@ -514,8 +523,15 @@
|
||||
<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]">
|
||||
{#each prog.milestones
|
||||
.filter(m => m.achieved)
|
||||
.sort((a, b) => {
|
||||
if (!a.achievedDate && !b.achievedDate) return 0;
|
||||
if (!a.achievedDate) return 1;
|
||||
if (!b.achievedDate) return -1;
|
||||
return a.achievedDate.localeCompare(b.achievedDate);
|
||||
}) as milestone (milestone.id)}
|
||||
<Container class="p-3 min-h-[130px] {isRecent(milestone.achievedDate) ? 'recent-achievement' : ''}">
|
||||
<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">
|
||||
@@ -625,3 +641,17 @@
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||
|
||||
<style>
|
||||
@keyframes breathe-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 6px 2px rgba(251, 146, 60, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 18px 6px rgba(251, 146, 60, 0.65);
|
||||
}
|
||||
}
|
||||
:global(.recent-achievement) {
|
||||
animation: breathe-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 5.4 MiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
@@ -0,0 +1,158 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
getRandomVerses,
|
||||
getRandomVersesFromBook,
|
||||
extractVerses,
|
||||
formatReference,
|
||||
getAllNKJVVerses,
|
||||
} from "$lib/server/xml-bible";
|
||||
import { bibleBooks } from "$lib/server/bible";
|
||||
|
||||
// Build a map of bookName → chapters → verse numbers from the full XML dump.
|
||||
// This is the ground truth of what the XML actually contains.
|
||||
function buildVerseMap(): Map<string, Map<number, number[]>> {
|
||||
const allVerses = getAllNKJVVerses();
|
||||
const map = new Map<string, Map<number, number[]>>();
|
||||
for (const { book, chapter, verse } of allVerses) {
|
||||
if (!map.has(book)) map.set(book, new Map());
|
||||
const chapters = map.get(book)!;
|
||||
if (!chapters.has(chapter)) chapters.set(chapter, []);
|
||||
chapters.get(chapter)!.push(verse);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const verseMap = buildVerseMap();
|
||||
|
||||
// ─── 1. XML data completeness ────────────────────────────────────────────────
|
||||
|
||||
describe("XML data completeness — all 66 books present", () => {
|
||||
test("getAllNKJVVerses returns a non-empty array", () => {
|
||||
const verses = getAllNKJVVerses();
|
||||
expect(verses.length).toBeGreaterThan(30_000); // NKJV has ~31,102 verses
|
||||
});
|
||||
|
||||
test("every Bible book appears at least once", () => {
|
||||
const booksInXml = new Set(verseMap.keys());
|
||||
const missing: string[] = [];
|
||||
for (const book of bibleBooks) {
|
||||
if (!booksInXml.has(book.name)) missing.push(book.name);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test("no book has zero chapters", () => {
|
||||
for (const book of bibleBooks) {
|
||||
const chapters = verseMap.get(book.name);
|
||||
expect(chapters).toBeDefined();
|
||||
expect(chapters!.size).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("no chapter has zero verses", () => {
|
||||
for (const [bookName, chapters] of verseMap) {
|
||||
for (const [chapterNum, verses] of chapters) {
|
||||
expect(verses.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("verse numbers within each chapter are sequential with no gaps", () => {
|
||||
const gaps: string[] = [];
|
||||
for (const [bookName, chapters] of verseMap) {
|
||||
for (const [chapterNum, verses] of chapters) {
|
||||
const sorted = [...verses].sort((a, b) => a - b);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (sorted[i] !== i + 1) {
|
||||
gaps.push(`${bookName} ${chapterNum}: expected verse ${i + 1}, got ${sorted[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(gaps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Every book can be returned as a daily verse ──────────────────────────
|
||||
|
||||
describe("getRandomVersesFromBook — every book can produce a daily verse", () => {
|
||||
for (let bookNumber = 1; bookNumber <= 66; bookNumber++) {
|
||||
const book = bibleBooks.find((b) => b.order === bookNumber)!;
|
||||
|
||||
test(`book ${bookNumber} (${book.name}) returns 3 consecutive verses`, () => {
|
||||
const result = getRandomVersesFromBook(bookNumber, 3);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.bookId).toBe(book.id);
|
||||
expect(result!.bookName).toBe(book.name);
|
||||
expect(result!.chapter).toBeGreaterThan(0);
|
||||
expect(result!.startVerse).toBeGreaterThan(0);
|
||||
expect(result!.endVerse).toBe(result!.startVerse + 2);
|
||||
expect(result!.verses).toHaveLength(3);
|
||||
for (const verse of result!.verses) {
|
||||
expect(verse.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 3. Every chapter with ≥ 3 verses is reachable via extractVerses ─────────
|
||||
|
||||
describe("extractVerses — every eligible chapter is reachable", () => {
|
||||
for (let bookNumber = 1; bookNumber <= 66; bookNumber++) {
|
||||
const book = bibleBooks.find((b) => b.order === bookNumber)!;
|
||||
const chapters = verseMap.get(book.name);
|
||||
if (!chapters) continue;
|
||||
|
||||
for (const [chapterNum, verses] of chapters) {
|
||||
if (verses.length < 3) continue; // getRandomVerses skips these — that's expected
|
||||
|
||||
test(`${book.name} ${chapterNum} (${verses.length} verses) — extractVerses returns 3`, () => {
|
||||
const result = extractVerses(bookNumber, chapterNum, 1, 3);
|
||||
expect(result).toHaveLength(3);
|
||||
for (const v of result) {
|
||||
expect(v.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 4. getRandomVerses (the actual daily-verse function) ────────────────────
|
||||
|
||||
describe("getRandomVerses", () => {
|
||||
test("returns a well-formed result", () => {
|
||||
const result = getRandomVerses(3);
|
||||
expect(result).not.toBeNull();
|
||||
expect(bibleBooks.some((b) => b.id === result!.bookId)).toBe(true);
|
||||
expect(result!.chapter).toBeGreaterThan(0);
|
||||
expect(result!.startVerse).toBeGreaterThan(0);
|
||||
expect(result!.endVerse).toBe(result!.startVerse + 2);
|
||||
expect(result!.verses).toHaveLength(3);
|
||||
for (const v of result!.verses) {
|
||||
expect(v.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("bookId is always a known Bible book", () => {
|
||||
const knownIds = new Set(bibleBooks.map((b) => b.id));
|
||||
// Run several times to increase confidence
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = getRandomVerses(3);
|
||||
expect(result).not.toBeNull();
|
||||
expect(knownIds.has(result!.bookId)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. formatReference ──────────────────────────────────────────────────────
|
||||
|
||||
describe("formatReference", () => {
|
||||
test("single verse: Book C:V", () => {
|
||||
expect(formatReference("Genesis", 1, 1, 1)).toBe("Genesis 1:1");
|
||||
});
|
||||
|
||||
test("verse range: Book C:V1-V2", () => {
|
||||
expect(formatReference("Matthew", 5, 3, 5)).toBe("Matthew 5:3-5");
|
||||
});
|
||||
});
|
||||