mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 13:54:10 -04:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97bff35323 | ||
|
|
4a53e09ab3 | ||
|
|
2fb484ebaa | ||
|
|
21efdd4eef | ||
|
|
be0d7ad297 | ||
|
|
99552c57ad | ||
|
|
efc9900de1 | ||
|
|
e3ca264c54 | ||
|
|
f5e16c7e71 | ||
|
|
e45ac28169 | ||
|
|
3d578a9eb8 | ||
|
|
db04da6a2c | ||
|
|
321fac9aa8 | ||
|
|
4a5aef5a3d | ||
|
|
f98ab24d2e | ||
|
|
c5b333bbb3 | ||
|
|
e842923d81 | ||
|
|
51bfb53a39 | ||
|
|
45d33b6bad | ||
|
|
3eb3a968dc | ||
|
|
67d9757f98 | ||
|
|
b6b41b6ba9 | ||
|
|
bdc08bc58e | ||
|
|
83cfcc66c0 | ||
|
|
e878dea235 | ||
|
|
252edc3a6d | ||
|
|
75b13280ef | ||
|
|
7007df2966 | ||
|
|
61673a646d | ||
|
|
1eb8eb2f04 | ||
|
|
ae4482a551 | ||
|
|
884bbe65c7 | ||
|
|
3de55ba216 | ||
|
|
6e74fffb65 | ||
|
|
1ae2b2ac6c | ||
|
|
a188be167b | ||
|
|
e550965086 | ||
|
|
03429b17cc | ||
|
|
3ee7331510 | ||
|
|
592fa917cd | ||
|
|
ad1774e6b0 | ||
|
|
e1a665ba63 | ||
|
|
f3c9feaf97 | ||
|
|
a5cf248e29 | ||
|
|
f9f3f3de12 | ||
|
|
abab886d1a | ||
|
|
acc82af7cd | ||
|
|
fc674d6008 | ||
|
|
087a476df8 | ||
|
|
ba45cbdc37 | ||
|
|
1de436534c | ||
|
|
3bcd7ea266 | ||
|
|
7ecc84ffbc | ||
|
|
3d78353a90 | ||
|
|
bd36f29419 | ||
|
|
3036264d44 | ||
|
|
6554ef8f41 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ llms-*
|
|||||||
embeddings*
|
embeddings*
|
||||||
*bible.xml
|
*bible.xml
|
||||||
engwebu_usfx.xml
|
engwebu_usfx.xml
|
||||||
|
|
||||||
|
deploy.log
|
||||||
|
bibdle.socket
|
||||||
@@ -1,149 +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.
|
|
||||||
|
|
||||||
### 4. playground-link
|
|
||||||
|
|
||||||
Generates a Svelte Playground link with the provided code.
|
|
||||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
|
||||||
- **Styling**: Tailwind CSS 4
|
|
||||||
- **Database**: SQLite with Drizzle ORM
|
|
||||||
- **Auth**: Session-based authentication using @oslojs/crypto (SHA-256 hashed tokens)
|
|
||||||
- **Deployment**: Node.js adapter for production builds
|
|
||||||
- **External API**: bible-api.com for fetching random verses
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start development server
|
|
||||||
bun run dev
|
|
||||||
|
|
||||||
# Type checking
|
|
||||||
bun run check
|
|
||||||
bun run check:watch
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# Preview production build
|
|
||||||
bun run preview
|
|
||||||
|
|
||||||
# Database operations
|
|
||||||
bun run db:push # Push schema changes to database
|
|
||||||
bun run db:generate # Generate migrations (DO NOT RUN)
|
|
||||||
bun run db:migrate # Run migrations (DO NOT RUN)
|
|
||||||
bun run db:studio # Open Drizzle Studio GUI
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema (`src/lib/server/db/schema.ts`)
|
|
||||||
|
|
||||||
- **user**: User accounts with id and age
|
|
||||||
- **session**: Auth sessions linked to users with expiration timestamps
|
|
||||||
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
|
||||||
|
|
||||||
Sessions expire after 30 days and are automatically renewed when less than 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)
|
|
||||||
- Popularity (2-10, affects grading - higher is more popular)
|
|
||||||
|
|
||||||
### Daily Verse System (`src/routes/+page.server.ts`)
|
|
||||||
|
|
||||||
The `getTodayVerse()` function:
|
|
||||||
1. Checks database for existing verse for today's date
|
|
||||||
2. If none exists, fetches from bible-api.com (random verse + 2 consecutive verses)
|
|
||||||
3. Caches in database with UTC date key
|
|
||||||
4. Returns verse with book metadata for the game
|
|
||||||
|
|
||||||
### Game Logic (`src/routes/+page.svelte`)
|
|
||||||
|
|
||||||
**State Management:**
|
|
||||||
- `guesses` array stores game state in localStorage keyed by date
|
|
||||||
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
|
|
||||||
- `isWon` is derived from whether any guess matches the correct book
|
|
||||||
|
|
||||||
**Grading System:**
|
|
||||||
```javascript
|
|
||||||
// Grade formula combines performance + difficulty
|
|
||||||
performanceScore = max(0, 10 - numGuesses)
|
|
||||||
difficulty = 14 - popularity
|
|
||||||
totalScore = performanceScore + difficulty * 0.8
|
|
||||||
|
|
||||||
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hint System:**
|
|
||||||
- ✅ Green checkmark: Exact match
|
|
||||||
- 🟩 Green square: Section matches
|
|
||||||
- 🟧 Orange square: Testament matches (shared results)
|
|
||||||
- ‼️ Double exclamation: Adjacent book in Bible order
|
|
||||||
- 🟥 Red square: No match
|
|
||||||
|
|
||||||
### Authentication System (`src/lib/server/auth.ts`)
|
|
||||||
|
|
||||||
- Token-based sessions with SHA-256 hashing
|
|
||||||
- Cookies store session tokens, validated on each request
|
|
||||||
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
|
|
||||||
- Note: Currently the schema includes user table but auth UI is not yet implemented
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- `src/routes/+page.svelte` - Main game UI and client-side logic
|
|
||||||
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
|
|
||||||
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
|
|
||||||
- `src/lib/server/bible.ts` - Bible book utility functions
|
|
||||||
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
|
|
||||||
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
|
|
||||||
- `src/hooks.server.ts` - SvelteKit server hook for session validation
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Required in `.env`:
|
|
||||||
- `DATABASE_URL` - Path to SQLite database file (e.g., `./local.db`)
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
The project uses `@sveltejs/adapter-node` for deployment. The build output is a Node.js server that can be run with systemd or similar process managers. See `bibdle.service` and `bibdle.socket` for systemd configuration.
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) - not stores or reactive declarations
|
|
||||||
- The schema includes authentication tables but the login/signup UI is not yet implemented
|
|
||||||
- Daily verses are cached permanently in the database to ensure consistency
|
|
||||||
- LocalStorage persists guesses per day using the key pattern `bibdle-guesses-${date}`
|
|
||||||
- The game validates book IDs from the API against the hardcoded `bibleBooks` array
|
|
||||||
@@ -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` (1–66, used for adjacency detection)
|
||||||
|
|
||||||
|
### Game Logic
|
||||||
|
|
||||||
|
- `src/lib/utils/game.ts` — `evaluateGuess()` compares a guess to the target book and returns `testamentMatch`, `sectionMatch`, `adjacent`, and `firstLetterMatch` flags. `getGrade()` maps guess count to a letter grade (S+ down to C). Includes a special-case so that numbered Epistles (e.g. "1 John") match on first letter against any other numbered Epistle.
|
||||||
|
- `src/lib/stores/game-persistence.svelte.ts` — reactive store that keeps guesses and per-day flags in sync with `localStorage`, keyed by date (`bibdle-guesses-${date}`). Resolves the player identity (logged-in user ID, or a locally generated anonymous UUID) and restores state.
|
||||||
|
- `src/lib/utils/share.ts` — generates the share grid and text. Hint emojis: ✅ exact · 🟩 section · 🟧 testament · ‼️ adjacent · 🟥 no match.
|
||||||
|
|
||||||
|
### Daily Verse System
|
||||||
|
|
||||||
|
`src/lib/server/daily-verse.ts` → `getVerseForDate(date)`: returns the cached verse for a date if present, otherwise fetches a random verse from the local XML Bible and stores it permanently. The XML Bible is read and parsed in `src/lib/server/xml-bible.ts`; `src/lib/server/bible-api.ts` wraps it to produce a verse with a validated `bookId`, `reference`, and `verseText`.
|
||||||
|
|
||||||
|
### Community Verse Submissions
|
||||||
|
|
||||||
|
Authenticated users who have solved today's puzzle can submit a verse for scheduling as a future "verse of the day." Selection is dropdown-based (cascading Book → Chapter → Verse selects), so content is always canonical NKJV text — no free-text entry. Each submission immediately reserves a concrete future date.
|
||||||
|
|
||||||
|
- `src/lib/server/verse-submission.ts` — the 3-verse window composer (`composeVerseWindow`, fall-forward, never crosses a book), `formatWindowReference` (same-chapter hyphen / cross-chapter en-dash), and the assign-at-submit scheduling scan (empty / no-back-to-back-with-committed-neighbors / 60-day-repeat rules, transaction + retry-on-unique-conflict).
|
||||||
|
- `getVerseForDate(date)` serves pre-written submission rows unchanged on their day; gap days (no committed row) are filled lazily by the random path, now extended to avoid the book of any committed neighbor (D-1 / D+1).
|
||||||
|
- **Rate limiting:** one submission per user per rolling 7×24h window, measured in server UTC (not gameable). The win-screen button is always visible; it is greyed out with a countdown timer while the cooldown is active.
|
||||||
|
- **Attribution is anonymous everywhere** except the admin-only `/scheduled-verses` view (the single exception, gated to `ADMIN_EMAIL` in `src/lib/server/admin.ts`), which surfaces submitter email for moderation.
|
||||||
|
|
||||||
|
### Authentication (`src/lib/server/auth.ts`)
|
||||||
|
|
||||||
|
- Token: base64url-encoded random bytes; stored as a SHA-256 hash in the DB. Cookie name: `auth-session`.
|
||||||
|
- Anonymous users are identified by a client-generated UUID in `localStorage`. On sign-up, `migrateAnonymousStats()` re-attributes the user's `dailyCompletions` rows from the anonymous ID to the new user ID (duplicates for overlapping dates are dropped).
|
||||||
|
- Apple Sign-In (`src/lib/server/apple-auth.ts`) and Google Sign-In (`src/lib/server/google-auth.ts`) are supported; the SvelteKit CSRF config trusts `https://appleid.apple.com` for the cross-origin `form_post` callback.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | Main game (classic mode). Verse display, search input, guesses table, win screen, streak/percentile. `+page.ts` sets `ssr = false` so the load runs client-side with the true local date, then POSTs that date to `/api/daily-verse`. |
|
||||||
|
| `/imposter` | Imposter Mode — four verses are shown; three come from one book and one from a different book. Pick the one that doesn't belong. |
|
||||||
|
| `/random` | Debug page showing a random verse from the NKJV. |
|
||||||
|
| `/greek-random` | Debug page showing a random verse in parallel Greek (1904) / English (NKJV). |
|
||||||
|
| `/similarity` | Search a sentence and return the most similar Bible verses via the (currently disabled) embeddings model. |
|
||||||
|
| `/about` | About page with the project's backstory and social links. |
|
||||||
|
| `/global` | Public stats dashboard: completions today/all-time, unique & weekly & monthly players, active streak distribution, 14-day completions trend, retention/return-rate metrics. |
|
||||||
|
| `/progress` | Personal progress page (requires auth): activity calendar, 66-book grid with mastery tiers, insights, and achievements/milestones. |
|
||||||
|
| `/stats` | Personal stats page (requires auth); returns `requiresAuth: true` for unauthenticated visitors and renders a sign-in modal. |
|
||||||
|
| `/dev` | Local-time / countdown debug page. |
|
||||||
|
| `/scheduled-verses` | Admin-only (gated to `ADMIN_EMAIL` in `src/lib/server/admin.ts`). Full historical + future log of `verseSubmissions` joined to `dailyVerses` and `user`, sorted by scheduled date. The sole surface where submitter identity is shown. Not linked from the UI. |
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/daily-verse` | Fetch (and cache) the verse for a given `YYYY-MM-DD` date. |
|
||||||
|
| `POST /api/submit-completion` | Submit a game result (`anonymousId`, `date`, `guessCount`, `guesses`); returns solve rank, guess rank, total solves, average guesses, ties, and percentile. Unique on `(anonymousId, date)`. |
|
||||||
|
| `GET /api/streak?anonymousId=X&localDate=Y` | Current streak: walks backwards from the client's local date through completed dates, counting consecutive days. Single-day streaks are reported as 0 (minimum displayed streak is 2). |
|
||||||
|
| `GET /api/streak-percentile?streak=N&localDate=Y` | Streak percentile ranking computed across all players' current streaks. |
|
||||||
|
| `GET /api/stats` | Aggregated stats used by the `/global` dashboard. |
|
||||||
|
| `GET /api/imposter` | Generates a four-verse imposter-mode round. |
|
||||||
|
| `POST /api/similar-verses` | Semantic verse search via embeddings. |
|
||||||
|
| `POST /api/send-daily-verse` | Cron-only (bearer `CRON_SECRET`); posts today's verse to the Discord webhook. |
|
||||||
|
| `POST /api/dev/seed-history` | Dev seeding helper. |
|
||||||
|
| `POST /api/submit-verse` | Auth required. Accept `{ bookId, chapter, verse, localDate }`; runs the solved-today gate, 7-day cooldown, structural validation, and the assign-at-submit scheduling scan; returns `{ scheduledDate, reference, windowText }`. |
|
||||||
|
| `GET /api/submit-verse/status?localDate=YYYY-MM-DD` | Auth required. Win-screen button state: `canSubmit`, `cooldownEndsAt`, `lastSubmission`, and this user's not-yet-reached upcoming submissions. |
|
||||||
|
| `GET /api/bible/structure` | Public. 66-book verse counts per chapter (cascading-dropdown payload, cached indefinitely). |
|
||||||
|
| `GET /api/verse-window?bookId=gen&chapter=1&verse=1` | Public. Live 3-verse preview (fall-forward window) for the submit selector. |
|
||||||
|
|
||||||
|
### Other Endpoints
|
||||||
|
|
||||||
|
- `GET /feed.xml` — RSS feed of daily verses.
|
||||||
|
- `GET /sitemap.xml` — XML sitemap for SEO.
|
||||||
|
|
||||||
|
## Critical: Date/Time Handling
|
||||||
|
|
||||||
|
Bibdle is played across many timezones. The verse shown must always be the verse for the calendar date at **the player's location** — never the server's timezone, never UTC.
|
||||||
|
|
||||||
|
- The client computes today's date with `new Date().toLocaleDateString("en-CA")` (`YYYY-MM-DD`) and sends it to the server.
|
||||||
|
- Server-side date arithmetic always uses UTC methods on the client-provided date string (`new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`) to avoid timezone drift — see `/api/streak` and `/api/send-daily-verse`.
|
||||||
|
- `/` has `ssr = false` so the load runs client-side with the real local date.
|
||||||
|
- The main page also reloads itself if the tab regains focus on a new calendar day.
|
||||||
|
- The user's local date is never placed in the URL; it is only ever sent to API routes.
|
||||||
|
|
||||||
|
### Streak Calculation
|
||||||
|
|
||||||
|
A streak counts consecutive calendar days (in the player's local timezone) on which the puzzle was completed:
|
||||||
|
|
||||||
|
- The client passes `localDate`; the server never uses its own clock.
|
||||||
|
- `/api/streak` walks backwards from `localDate` through `dailyCompletions`, counting each completed day; stops at the first missing day. It's called from the win screen, so today is typically completed. Single-day streaks are reported as `0` — the minimum displayed streak is 2.
|
||||||
|
- `/api/streak-percentile` (which ranks all players) anchors on today-if-played-else-yesterday, so a player's streak isn't zeroed mid-day before they've had a chance to complete today's puzzle.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/routes/+page.svelte` | Main game UI and client-side logic |
|
||||||
|
| `src/routes/+page.server.ts` / `+page.ts` | Server load (user/session) + client load (`ssr: false`, fetches the daily verse) |
|
||||||
|
| `src/routes/+layout.svelte` | App shell, title animation, theme toggle, analytics injection |
|
||||||
|
| `src/lib/server/auth.ts` | Session management, password hashing, anonymous→user migration |
|
||||||
|
| `src/lib/server/apple-auth.ts`, `google-auth.ts` | OAuth providers |
|
||||||
|
| `src/lib/server/daily-verse.ts` | Per-date verse caching/lookup |
|
||||||
|
| `src/lib/server/xml-bible.ts` | Local XML Bible parsing (NKJV / Greek / Swedish) |
|
||||||
|
| `src/lib/server/bible-api.ts` | Random verse fetching on top of the XML parser |
|
||||||
|
| `src/lib/server/bible.ts` | Bible book utility functions |
|
||||||
|
| `src/lib/server/milestones.ts` | Achievement/milestone calculation (set-completion, streak, etc.) |
|
||||||
|
| `src/lib/server/admin.ts` | `ADMIN_EMAIL` constant for the `/scheduled-verses` admin route. |
|
||||||
|
| `src/lib/server/verse-submission.ts` | Window composer + scheduling scan for community verse submissions. |
|
||||||
|
| `src/lib/components/SubmitVerse.svelte` | Win-screen submit button (logged-out sign-in dropdown / cooldown + countdown / cascading selects + preview + submit). |
|
||||||
|
| `src/lib/types/bible.ts` | 66-book metadata and TypeScript types |
|
||||||
|
| `src/lib/utils/game.ts` | Guess evaluation and grading |
|
||||||
|
| `src/lib/utils/share.ts` | Share grid/text generation |
|
||||||
|
| `src/lib/utils/streak.ts`, `stats-client.ts`, `stats.ts` | Client-side streak/stats fetching and formatting |
|
||||||
|
| `src/lib/stores/game-persistence.svelte.ts` | Reactive localStorage-backed game state |
|
||||||
|
| `src/lib/server/db/schema.ts` | Drizzle ORM schema |
|
||||||
|
| `src/hooks.server.ts` | Session validation hook; (commented-out) embeddings init |
|
||||||
|
| `tests/` | Bun test suites: timezone, game, bible, stats, share, sign-in migration |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Production uses `@sveltejs/adapter-node` run under Bun via a systemd service. `deploy.sh` pulls latest, installs deps, builds, and restarts `bibdle.service` (which runs `bun --bun build/index.js` on port 5173 with `DATABASE_URL=prod.db`). See `bibdle.service` for the unit file.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Bibdle was created as a small, daily nudge to read the Bible — inspired by the Wordle story of a personal project that grew organically through word of mouth. The full backstory lives on the `/about` page.
|
||||||
+7
-6
@@ -1,16 +1,17 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Bibdle SvelteKit App
|
Description=Bibdle SvelteKit App
|
||||||
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
||||||
Requires=bibdle.socket
|
After=network-online.target
|
||||||
After=network-online.target bibdle.socket
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=ORIGIN=https://bibdle.com
|
Environment=ORIGIN=https://bibdle.com
|
||||||
Environment=DATABASE_URL=local.db
|
Environment=DATABASE_URL=prod.db
|
||||||
Environment=IDLE_TIMEOUT=60
|
Environment=IDLE_TIMEOUT=300
|
||||||
WorkingDirectory=/home/george/projects/bibdle
|
Environment=PORT=5173
|
||||||
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
|
WorkingDirectory=/home/xenia/projects/bibdle
|
||||||
|
#ExecStart=/home/xenia/.nvm/versions/node/v24.13.0/bin/node build/index.js
|
||||||
|
ExecStart=/home/xenia/.bun/bin/bun --bun build/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[Socket]
|
|
||||||
ListenStream=5173
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
+385
@@ -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 |
@@ -1,22 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
echo "Pulling latest changes..."
|
|
||||||
git pull
|
|
||||||
|
|
||||||
echo "Installing dependencies..."
|
|
||||||
bun i
|
|
||||||
|
|
||||||
echo "Pushing database changes..."
|
|
||||||
bun run db:generate
|
|
||||||
bun run db:migrate
|
|
||||||
|
|
||||||
echo "Building..."
|
|
||||||
bun --bun run build
|
|
||||||
|
|
||||||
echo "Restarting service..."
|
|
||||||
sudo systemctl restart bibdle-test
|
|
||||||
|
|
||||||
echo "Done!"
|
|
||||||
@@ -3,16 +3,24 @@ set -e
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BUN=$(which bun)
|
||||||
|
|
||||||
echo "Pulling latest changes..."
|
echo "Pulling latest changes..."
|
||||||
git pull
|
PULL_OUTPUT=$(git pull)
|
||||||
|
echo "$PULL_OUTPUT"
|
||||||
|
if [ "$PULL_OUTPUT" = "Already up to date." ]; then
|
||||||
|
echo "Nothing to deploy."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
bun i
|
$BUN i
|
||||||
|
|
||||||
echo "Building..."
|
echo "Building..."
|
||||||
bun --bun run build
|
$BUN --bun run build
|
||||||
|
|
||||||
echo "Restarting service..."
|
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||||
sudo systemctl restart bibdle
|
echo "Restarting service ($SERVICE_NAME)..."
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
@@ -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`);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Executable
+87
@@ -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"
|
||||||
+2
-2
@@ -18,8 +18,6 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
|
||||||
"@oslojs/encoding": "^1.1.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
@@ -37,7 +35,9 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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:S1–C2: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.
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
# Seed the database with 10 fake completions with random anonymous_ids
|
||||||
|
# Useful for testing streak percentile and stats features
|
||||||
|
|
||||||
|
DB_PATH="dev.db"
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
NOW=$(date +%s)
|
||||||
|
|
||||||
|
echo "Seeding 10 fake completions for date: $TODAY"
|
||||||
|
|
||||||
|
for i in {1..50}; do
|
||||||
|
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
ANON_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
GUESS_COUNT=$(( (RANDOM % 6) + 1 )) # 1–6 guesses
|
||||||
|
|
||||||
|
sqlite3 "$DB_PATH" "
|
||||||
|
INSERT OR IGNORE INTO daily_completions (id, anonymous_id, date, guess_count, completed_at)
|
||||||
|
VALUES ('$ID', '$ANON_ID', '$TODAY', $GUESS_COUNT, $NOW);
|
||||||
|
"
|
||||||
|
|
||||||
|
echo " [$i] anon=$ANON_ID guesses=$GUESS_COUNT"
|
||||||
|
done
|
||||||
|
|
||||||
|
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '$TODAY';")
|
||||||
|
echo "✓ Done. Total completions for $TODAY: $TOTAL"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||||
|
import { generateShareText } from '../src/lib/utils/share';
|
||||||
|
import { bibleBooks } from '../src/lib/types/bible';
|
||||||
|
|
||||||
|
const NUM_VERSES = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < NUM_VERSES; i++) {
|
||||||
|
const verse = await fetchRandomVerse();
|
||||||
|
|
||||||
|
// Build a fake "solved in N guesses" scenario with some wrong guesses first
|
||||||
|
const correctBook = bibleBooks.find((b) => b.id === verse.bookId)!;
|
||||||
|
const wrongBook = bibleBooks.find((b) => b.id !== verse.bookId)!;
|
||||||
|
const guessCount = Math.floor(Math.random() * 5) + 1;
|
||||||
|
const guesses = [
|
||||||
|
...Array(guessCount - 1).fill(null).map(() => ({
|
||||||
|
book: wrongBook,
|
||||||
|
testamentMatch: wrongBook.testament === correctBook.testament,
|
||||||
|
sectionMatch: wrongBook.section === correctBook.section,
|
||||||
|
adjacent: Math.abs(wrongBook.order - correctBook.order) === 1,
|
||||||
|
})),
|
||||||
|
{ book: correctBook, testamentMatch: true, sectionMatch: true, adjacent: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fakeStreak = Math.random() > 0.5 ? Math.floor(Math.random() * 14) + 2 : 0;
|
||||||
|
|
||||||
|
const shareText = generateShareText({
|
||||||
|
guesses,
|
||||||
|
correctBookId: verse.bookId,
|
||||||
|
dailyVerseDate: new Date().toISOString().slice(0, 10),
|
||||||
|
chapterCorrect: guessCount === 1 && Math.random() > 0.5,
|
||||||
|
isLoggedIn: Math.random() > 0.5,
|
||||||
|
streak: fakeStreak > 0 ? fakeStreak : undefined,
|
||||||
|
origin: 'https://bibdle.com',
|
||||||
|
verseText: verse.verseText,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n── Verse ${i + 1}: ${verse.reference} ──`);
|
||||||
|
console.log(`RAW: ${verse.verseText}`);
|
||||||
|
console.log('─'.repeat(40));
|
||||||
|
console.log(shareText);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||||
|
import { getVerseSnippet } from '../src/lib/utils/share';
|
||||||
|
|
||||||
|
const NUM_VERSES = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < NUM_VERSES; i++) {
|
||||||
|
const verse = await fetchRandomVerse();
|
||||||
|
|
||||||
|
console.log(getVerseSnippet(verse.verseText));
|
||||||
|
}
|
||||||
@@ -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:31–2: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:S1–C2: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.
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
<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" />
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+3
-3
@@ -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;
|
||||||
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
|||||||
|
|
||||||
// Initialize embeddings on server start (runs once on module load)
|
// Initialize embeddings on server start (runs once on module load)
|
||||||
const verses = getAllNKJVVerses();
|
const verses = getAllNKJVVerses();
|
||||||
await initializeEmbeddings(verses);
|
// await initializeEmbeddings(verses);
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -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>
|
||||||
|
2–3 guesses
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm bg-amber-400"></span>
|
||||||
|
4–5 guesses
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm bg-orange-500"></span>
|
||||||
|
6–7 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
return chapterCounts[bookId] || 1;
|
return chapterCounts[bookId] || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6 random chapter options including the correct one
|
// Generate 4 random chapter options including the correct one
|
||||||
function generateChapterOptions(
|
function generateChapterOptions(
|
||||||
correctChapter: number,
|
correctChapter: number,
|
||||||
totalChapters: number,
|
totalChapters: number,
|
||||||
@@ -98,14 +98,14 @@
|
|||||||
const options = new Set<number>();
|
const options = new Set<number>();
|
||||||
options.add(correctChapter);
|
options.add(correctChapter);
|
||||||
|
|
||||||
if (totalChapters >= 6) {
|
if (totalChapters >= 4) {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter =
|
const randomChapter =
|
||||||
Math.floor(Math.random() * totalChapters) + 1;
|
Math.floor(Math.random() * totalChapters) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
@@ -167,18 +167,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 dark:from-amber-900/40 dark:to-yellow-900/30 text-gray-800 dark:text-gray-100 shadow-md"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
Bonus Challenge
|
||||||
Guess the chapter for an even higher grade
|
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||||
|
>— guess the chapter for an even higher grade</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
{#each chapterOptions as chapter (chapter)}
|
||||||
>
|
|
||||||
{#each chapterOptions as chapter}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => handleChapterSelect(chapter)}
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
disabled={hasAnswered}
|
disabled={hasAnswered}
|
||||||
@@ -193,8 +193,8 @@
|
|||||||
? isCorrect
|
? isCorrect
|
||||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
: "bg-red-400 text-white border-red-500"
|
: "bg-red-400 text-white border-red-500"
|
||||||
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
|
||||||
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
|
: "bg-white/80 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 text-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 hover:shadow-md cursor-pointer"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="inline-flex flex-col items-center bg-white/10 backdrop-blur-sm rounded-2xl border border-white/20 shadow-sm {className}"
|
class="inline-flex flex-col items-center bg-white/10 dark:bg-white/5 backdrop-blur-sm rounded-2xl border border-white/20 dark:border-white/10 shadow-sm {className}"
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
@@ -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(() => {
|
||||||
@@ -50,33 +58,40 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full flex flex-col flex-1">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm w-full flex-1"
|
||||||
>
|
>
|
||||||
{#if newVerseReady}
|
{#if newVerseReady}
|
||||||
<p
|
<p
|
||||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
>
|
>
|
||||||
Next Verse In
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||||
<p
|
<p
|
||||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mt-2"
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mt-2"
|
||||||
>
|
>
|
||||||
(refresh page to see the new verse)
|
(refresh page to see the new verse)
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p
|
<p
|
||||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
>
|
>
|
||||||
Next Verse In
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
class="text-4xl font-triodion font-black text-gray-800 tabular-nums"
|
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{timeUntilNext}
|
{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>
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-2 bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
class="flex flex-col items-center gap-2 bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
<p
|
||||||
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300 font-bold"
|
||||||
|
>
|
||||||
A project by George Powell & Silent Summit Co.
|
A project by George Powell & Silent Summit Co.
|
||||||
</p>
|
</p>
|
||||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||||
@@ -26,53 +27,8 @@
|
|||||||
<!-- Bluesky Social Media Button -->
|
<!-- Bluesky Social Media Button -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex items-center justify-center gap-6">
|
<div class="mt-8">
|
||||||
<a
|
<SocialLinks />
|
||||||
href="https://bsky.app/profile/snail.city"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
|
||||||
aria-label="Follow on Bluesky"
|
|
||||||
data-umami-event="Bluesky clicked"
|
|
||||||
>
|
|
||||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://x.com/pupperpowell"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
|
||||||
aria-label="Follow on Twitter"
|
|
||||||
data-umami-event="Twitter clicked"
|
|
||||||
>
|
|
||||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="mailto:george+bibdle@silentsummit.co"
|
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
|
||||||
aria-label="Send email"
|
|
||||||
data-umami-event="Email clicked"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8 text-gray-700"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
import Button from "$lib/components/Button.svelte";
|
import Button from "$lib/components/Button.svelte";
|
||||||
|
|
||||||
let { anonymousId }: { anonymousId: string | null } = $props();
|
type User = {
|
||||||
|
id: string;
|
||||||
|
email?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
appleId?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
let {
|
||||||
|
anonymousId,
|
||||||
|
user,
|
||||||
|
onSignIn,
|
||||||
|
}: { anonymousId: string | null; user: User; onSignIn: () => void } = $props();
|
||||||
|
|
||||||
let seeding = $state(false);
|
let seeding = $state(false);
|
||||||
|
|
||||||
async function seedHistory() {
|
async function seedHistory(days: number = 10) {
|
||||||
if (!browser || !anonymousId || seeding) return;
|
if (!browser || !anonymousId || seeding) return;
|
||||||
seeding = true;
|
seeding = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/dev/seed-history", {
|
const response = await fetch("/api/dev/seed-history", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ anonymousId })
|
body: JSON.stringify({ anonymousId, days })
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
alert(
|
alert(
|
||||||
@@ -46,6 +59,41 @@
|
|||||||
<div class="border-t-2 border-gray-400"></div>
|
<div class="border-t-2 border-gray-400"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
|
<a
|
||||||
|
href="/stats?{user
|
||||||
|
? `userId=${user.id}`
|
||||||
|
: `anonymousId=${anonymousId}`}&tz={encodeURIComponent(
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
)}"
|
||||||
|
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
📊 View Stats
|
||||||
|
</a>
|
||||||
|
<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}
|
||||||
|
<form method="POST" action="/auth/logout" use:enhance class="w-full">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
🚪 Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={onSignIn}
|
||||||
|
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
🔐 Sign In
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -113,7 +161,15 @@
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onclick={seedHistory}
|
onclick={() => seedHistory(1)}
|
||||||
|
disabled={seeding}
|
||||||
|
class="w-full py-4 md:py-2"
|
||||||
|
>
|
||||||
|
{seeding ? "Seeding..." : "Add 1 Day of History"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={() => seedHistory(10)}
|
||||||
disabled={seeding}
|
disabled={seeding}
|
||||||
class="w-full py-4 md:py-2"
|
class="w-full py-4 md:py-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,69 +77,63 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !hasGuesses}
|
{#if hasGuesses}
|
||||||
<Container class="p-6 text-center">
|
|
||||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
|
|
||||||
Instructions
|
|
||||||
</h2>
|
|
||||||
<p class="text-gray-700 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
|
||||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
Testament
|
Testament
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
Section
|
Section
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
First Letter
|
First Letter
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
Book
|
Book
|
||||||
</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">
|
<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,
|
firstGuess.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(firstGuess, "testament")}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Column -->
|
<!-- Section Column -->
|
||||||
<div
|
<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(
|
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,
|
firstGuess.sectionMatch,
|
||||||
guess.adjacent,
|
firstGuess.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(firstGuess, "section")}
|
||||||
{#if guess.adjacent}
|
{#if firstGuess.adjacent}
|
||||||
‼️
|
‼️
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -135,30 +142,173 @@
|
|||||||
<!-- First Letter Column -->
|
<!-- First Letter 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.firstLetterMatch,
|
firstGuess.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(firstGuess, "firstLetter")}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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(firstGuess)}"
|
||||||
)}"
|
|
||||||
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(firstGuess, "book")}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
|
||||||
|
<!-- 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">
|
||||||
|
<!-- 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="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<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="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<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="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<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="animation: none; opacity: 1; transform: none; {getBookBoxStyle(guess)}"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
|
>{getBoxContent(guess, "book")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/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"
|
||||||
|
> {suffix}</span
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs md:text-sm text-gray-300 font-medium">{label}</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
@@ -1,92 +1,355 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
import {
|
||||||
|
bibleBooks,
|
||||||
|
type BibleBook,
|
||||||
|
type BibleSection,
|
||||||
|
type Testament,
|
||||||
|
} from "$lib/types/bible";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
|
let {
|
||||||
|
searchQuery = $bindable(""),
|
||||||
|
guessedIds,
|
||||||
|
submitGuess,
|
||||||
|
guessCount = 0,
|
||||||
|
}: {
|
||||||
|
searchQuery: string;
|
||||||
|
guessedIds: SvelteSet<string>;
|
||||||
|
submitGuess: (id: string) => void;
|
||||||
|
guessCount: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let filteredBooks = $derived(
|
type DisplayMode = "simple" | "testament" | "sections";
|
||||||
bibleBooks.filter((book) =>
|
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
const displayMode = $derived<DisplayMode>(
|
||||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
|
||||||
submitGuess(filteredBooks[0].id);
|
);
|
||||||
}
|
|
||||||
}
|
const filteredBooks = $derived(
|
||||||
|
bibleBooks.filter((book) =>
|
||||||
|
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
type SimpleGroup = { books: BibleBook[] };
|
||||||
|
|
||||||
|
type TestamentGroup = {
|
||||||
|
testament: Testament;
|
||||||
|
label: string;
|
||||||
|
books: BibleBook[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SectionGroup = {
|
||||||
|
testament: Testament;
|
||||||
|
testamentLabel: string;
|
||||||
|
showTestamentHeader: boolean;
|
||||||
|
section: BibleSection;
|
||||||
|
books: BibleBook[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||||
|
const sorted = [...filteredBooks].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
return { books: sorted };
|
||||||
|
});
|
||||||
|
|
||||||
|
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||||
|
const old = filteredBooks
|
||||||
|
.filter((b) => b.testament === "old")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const newT = filteredBooks
|
||||||
|
.filter((b) => b.testament === "new")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const groups: TestamentGroup[] = [];
|
||||||
|
if (old.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
testament: "old",
|
||||||
|
label: "Old Testament",
|
||||||
|
books: old,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (newT.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
testament: "new",
|
||||||
|
label: "New Testament",
|
||||||
|
books: newT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||||
|
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||||
|
const seenKeys: Record<string, true> = {};
|
||||||
|
const orderedPairs: { testament: Testament; section: BibleSection }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
const key = `${book.testament}:${book.section}`;
|
||||||
|
if (!seenKeys[key]) {
|
||||||
|
seenKeys[key] = true;
|
||||||
|
orderedPairs.push({
|
||||||
|
testament: book.testament,
|
||||||
|
section: book.section,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: SectionGroup[] = [];
|
||||||
|
let lastTestament: Testament | null = null;
|
||||||
|
|
||||||
|
for (const pair of orderedPairs) {
|
||||||
|
const books = filteredBooks.filter(
|
||||||
|
(b) =>
|
||||||
|
b.testament === pair.testament &&
|
||||||
|
b.section === pair.section,
|
||||||
|
);
|
||||||
|
if (books.length === 0) continue;
|
||||||
|
|
||||||
|
const showTestamentHeader = pair.testament !== lastTestament;
|
||||||
|
lastTestament = pair.testament;
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
testament: pair.testament,
|
||||||
|
testamentLabel:
|
||||||
|
pair.testament === "old"
|
||||||
|
? "Old Testament"
|
||||||
|
: "New Testament",
|
||||||
|
showTestamentHeader,
|
||||||
|
section: pair.section,
|
||||||
|
books,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
// First book in display order for Enter key submission
|
||||||
|
const firstBookId = $derived.by<string | null>(() => {
|
||||||
|
if (filteredBooks.length === 0) return null;
|
||||||
|
if (displayMode === "simple") {
|
||||||
|
return simpleGroup.books[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (displayMode === "testament") {
|
||||||
|
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && firstBookId) {
|
||||||
|
submitGuess(firstBookId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const showBanner = $derived(guessCount >= 3);
|
||||||
|
const showBanner = false;
|
||||||
|
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if showBanner}
|
||||||
|
<p
|
||||||
|
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{#if bannerIsIndigo}
|
||||||
|
Testament & section groups now visible
|
||||||
|
{:else}
|
||||||
|
Old & New Testament groups now visible
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
aria-hidden="true"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
stroke-width="2"
|
||||||
/>
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
</svg>
|
/>
|
||||||
<input
|
</svg>
|
||||||
bind:value={searchQuery}
|
<input
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
bind:value={searchQuery}
|
||||||
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
|
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||||
onkeydown={handleKeydown}
|
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||||
autocomplete="off"
|
onkeydown={handleKeydown}
|
||||||
/>
|
autocomplete="off"
|
||||||
{#if searchQuery}
|
/>
|
||||||
<button
|
{#if searchQuery}
|
||||||
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
<button
|
||||||
onclick={() => (searchQuery = "")}
|
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||||
aria-label="Clear search"
|
onclick={() => (searchQuery = "")}
|
||||||
>
|
aria-label="Clear search"
|
||||||
<svg
|
>
|
||||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
<svg
|
||||||
fill="none"
|
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
viewBox="0 0 24 24"
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path
|
aria-hidden="true"
|
||||||
stroke-linecap="round"
|
>
|
||||||
stroke-linejoin="round"
|
<path
|
||||||
stroke-width="2"
|
stroke-linecap="round"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
stroke-linejoin="round"
|
||||||
/>
|
stroke-width="2"
|
||||||
</svg>
|
d="M6 18L18 6M6 6l12 12"
|
||||||
</button>
|
/>
|
||||||
{/if}
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{/if}
|
||||||
<ul
|
</div>
|
||||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
|
||||||
>
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
{#each filteredBooks as book (book.id)}
|
<ul
|
||||||
<li>
|
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
|
||||||
<button
|
role="listbox"
|
||||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
>
|
||||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
{#if displayMode === "simple"}
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
{#each simpleGroup.books as book (book.id)}
|
||||||
onclick={() => submitGuess(book.id)}
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
>
|
<button
|
||||||
<span
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
{guessedIds.has(book.id)
|
||||||
? 'line-through text-gray-500'
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
: ''}">{book.name}</span
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
>
|
onclick={() => submitGuess(book.id)}
|
||||||
<span class="ml-auto text-sm opacity-75"
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
>({book.testament.toUpperCase()})</span
|
>
|
||||||
>
|
<span
|
||||||
</button>
|
class="font-semibold dark:text-gray-100 {guessedIds.has(
|
||||||
</li>
|
book.id,
|
||||||
{/each}
|
)
|
||||||
</ul>
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
{:else if searchQuery}
|
: ''}"
|
||||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
>
|
||||||
{/if}
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else if displayMode === "testament"}
|
||||||
|
{#each testamentGroups as group (group.testament)}
|
||||||
|
<li role="presentation">
|
||||||
|
<div
|
||||||
|
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each group.books as book (book.id)}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={guessedIds.has(book.id)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||||
|
{guessedIds.has(book.id)
|
||||||
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||||
|
onclick={() => submitGuess(book.id)}
|
||||||
|
tabindex={guessedIds.has(book.id)
|
||||||
|
? -1
|
||||||
|
: 0}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-semibold {guessedIds.has(
|
||||||
|
book.id,
|
||||||
|
)
|
||||||
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||||
|
<li role="presentation">
|
||||||
|
{#if group.showTestamentHeader}
|
||||||
|
<div
|
||||||
|
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{group.testamentLabel}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
{group.section}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-100 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each group.books as book (book.id)}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={guessedIds.has(book.id)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||||
|
{guessedIds.has(book.id)
|
||||||
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||||
|
onclick={() => submitGuess(book.id)}
|
||||||
|
tabindex={guessedIds.has(book.id)
|
||||||
|
? -1
|
||||||
|
: 0}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-semibold {guessedIds.has(
|
||||||
|
book.id,
|
||||||
|
)
|
||||||
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{:else if searchQuery}
|
||||||
|
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">
|
||||||
|
No books found
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||||
|
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-6">
|
||||||
|
<a
|
||||||
|
href="https://bsky.app/profile/snail.city"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="Follow on Bluesky"
|
||||||
|
data-umami-event="Bluesky clicked"
|
||||||
|
>
|
||||||
|
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div>
|
||||||
|
|
||||||
|
<!-- <a
|
||||||
|
href="https://x.com/pupperpowell"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="Follow on Twitter"
|
||||||
|
data-umami-event="Twitter clicked"
|
||||||
|
>
|
||||||
|
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="mailto:george@snail.city"
|
||||||
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="Send email"
|
||||||
|
data-umami-event="Email clicked"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
streak,
|
||||||
|
streakPercentile = null,
|
||||||
|
}: {
|
||||||
|
streak: number;
|
||||||
|
streakPercentile?: number | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm flex-1 text-center"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-5xl font-triodion font-black text-orange-500 leading-none tabular-nums"
|
||||||
|
>
|
||||||
|
{streak}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-xs uppercase justify-center tracking-widest text-gray-500 dark:text-gray-200 font-triodion font-bold py-2 leading-tight"
|
||||||
|
>
|
||||||
|
day{streak === 1 ? "" : "s"} in a row
|
||||||
|
</p>
|
||||||
|
{#if streakPercentile !== null && streakPercentile <= 50}
|
||||||
|
<p
|
||||||
|
class="text-xs text-black dark:text-gray-200 w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 dark:border-t-stone-600 pt-2"
|
||||||
|
>
|
||||||
|
Top {streakPercentile}%
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let isDarkMode = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const stored = localStorage.getItem('bibdle-theme');
|
||||||
|
if (stored === 'dark') {
|
||||||
|
isDarkMode = true;
|
||||||
|
} else if (stored === 'light') {
|
||||||
|
isDarkMode = false;
|
||||||
|
} else {
|
||||||
|
isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isDarkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.classList.remove('light');
|
||||||
|
localStorage.setItem('bibdle-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.documentElement.classList.add('light');
|
||||||
|
localStorage.setItem('bibdle-theme', 'light');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
isDarkMode = !isDarkMode;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if browser}
|
||||||
|
<button
|
||||||
|
onclick={toggleTheme}
|
||||||
|
aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
class="flex items-center gap-2 p-1 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
{#if isDarkMode}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4"/>
|
||||||
|
<path d="M12 2v2"/>
|
||||||
|
<path d="M12 20v2"/>
|
||||||
|
<path d="m4.93 4.93 1.41 1.41"/>
|
||||||
|
<path d="m17.66 17.66 1.41 1.41"/>
|
||||||
|
<path d="M2 12h2"/>
|
||||||
|
<path d="M20 12h2"/>
|
||||||
|
<path d="m6.34 17.66-1.41 1.41"/>
|
||||||
|
<path d="m19.07 4.93-1.41 1.41"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs uppercase tracking-widest">
|
||||||
|
{isDarkMode ? 'Light mode' : 'Dark mode'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -1,73 +1,93 @@
|
|||||||
<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";
|
|
||||||
|
|
||||||
let {
|
interface VerseDisplayData {
|
||||||
data,
|
dailyVerse: {
|
||||||
isWon,
|
date: string;
|
||||||
blurChapter = false,
|
reference: string;
|
||||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
verseText: string;
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
};
|
||||||
let displayReference = $derived(
|
}
|
||||||
blurChapter
|
|
||||||
? dailyVerse.reference
|
|
||||||
.replace(/^Psalms /, "Psalm ")
|
|
||||||
.replace(/\s(\d+):/, " ?:")
|
|
||||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
|
||||||
);
|
|
||||||
let displayVerseText = $derived(
|
|
||||||
dailyVerse.verseText
|
|
||||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
|
||||||
.replace(/[,:;-—]$/, "...")
|
|
||||||
);
|
|
||||||
|
|
||||||
let showReference = $state(false);
|
let {
|
||||||
|
data,
|
||||||
|
isWon,
|
||||||
|
blurChapter = false,
|
||||||
|
}: { data: VerseDisplayData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||||
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
|
let displayReference = $derived(
|
||||||
|
blurChapter
|
||||||
|
? dailyVerse.reference
|
||||||
|
.replace(/^Psalms /, "Psalm ")
|
||||||
|
.replace(/\s(\d+):/, " ?:")
|
||||||
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||||
|
);
|
||||||
|
let displayVerseText = $derived(
|
||||||
|
dailyVerse.verseText
|
||||||
|
.replace(/^([a-z])/, (c: string) => c.toUpperCase())
|
||||||
|
.replace(/[,:;-—]$/, "..."),
|
||||||
|
);
|
||||||
|
|
||||||
// Delay showing reference until GuessesTable animation completes
|
let showReference = $state(false);
|
||||||
$effect(() => {
|
let copied = $state(false);
|
||||||
if (!isWon) {
|
|
||||||
showReference = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already won today (page reload case)
|
// Delay showing reference until GuessesTable animation completes
|
||||||
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
$effect(() => {
|
||||||
const alreadyWonToday = browser && localStorage.getItem(winTrackedKey) === "true";
|
if (!isWon) {
|
||||||
|
showReference = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (alreadyWonToday) {
|
// Check if user already won today (page reload case)
|
||||||
// User already won and is refreshing - show immediately
|
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||||
showReference = true;
|
const alreadyWonToday =
|
||||||
} else {
|
browser && localStorage.getItem(winTrackedKey) === "true";
|
||||||
// User just won this session - delay for animation
|
|
||||||
const animationDelay = 1800;
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
showReference = true;
|
|
||||||
}, animationDelay);
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
if (alreadyWonToday) {
|
||||||
}
|
// User already won and is refreshing - show immediately
|
||||||
});
|
showReference = true;
|
||||||
|
} else {
|
||||||
|
// User just won this session - delay for animation
|
||||||
|
const animationDelay = 1800;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
showReference = true;
|
||||||
|
}, animationDelay);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyVerse() {
|
||||||
|
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
|
<Container
|
||||||
<blockquote
|
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
>
|
||||||
>
|
<blockquote
|
||||||
{displayVerseText}
|
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
|
||||||
</blockquote>
|
>
|
||||||
<div
|
{displayVerseText}
|
||||||
class="transition-all duration-500 ease-in-out overflow-hidden"
|
</blockquote>
|
||||||
style="max-height: {showReference ? '200px' : '0px'};"
|
<div
|
||||||
>
|
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||||
{#if showReference}
|
style="max-height: {showReference ? '200px' : '0px'};"
|
||||||
<p
|
>
|
||||||
transition:fade={{ duration: 400 }}
|
{#if showReference}
|
||||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
<p
|
||||||
>
|
transition:fade={{ duration: 400 }}
|
||||||
{displayReference}
|
class="text-center text-lg! big-text text-green-600! dark:text-green-400! font-bold mt-8 bg-white/70 dark:bg-black/50 rounded-xl px-4 py-2"
|
||||||
</p>
|
>
|
||||||
{/if}
|
{displayReference}
|
||||||
</div>
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
+696
-109
@@ -1,10 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
import { getBookById, toOrdinal } from "$lib/utils/game";
|
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||||
import { onMount } from "svelte";
|
import {
|
||||||
|
getVerseSnippet,
|
||||||
|
shareResult,
|
||||||
|
copyToClipboard as clipboardCopy,
|
||||||
|
} from "$lib/utils/share";
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
import CountdownTimer from "./CountdownTimer.svelte";
|
import CountdownTimer from "./CountdownTimer.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;
|
||||||
@@ -31,7 +37,12 @@
|
|||||||
reference,
|
reference,
|
||||||
onChapterGuessCompleted,
|
onChapterGuessCompleted,
|
||||||
shareText,
|
shareText,
|
||||||
|
verseText,
|
||||||
streak = 0,
|
streak = 0,
|
||||||
|
streakPercentile = null,
|
||||||
|
isLoggedIn = false,
|
||||||
|
anonymousId = "",
|
||||||
|
localDate = "",
|
||||||
}: {
|
}: {
|
||||||
statsData: StatsData | null;
|
statsData: StatsData | null;
|
||||||
correctBookId: string;
|
correctBookId: string;
|
||||||
@@ -43,7 +54,12 @@
|
|||||||
reference: string;
|
reference: string;
|
||||||
onChapterGuessCompleted: () => void;
|
onChapterGuessCompleted: () => void;
|
||||||
shareText: string;
|
shareText: string;
|
||||||
|
verseText: string;
|
||||||
streak?: number;
|
streak?: number;
|
||||||
|
streakPercentile?: number | null;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
|
anonymousId?: string;
|
||||||
|
localDate?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -51,6 +67,25 @@
|
|||||||
typeof navigator !== "undefined" && "share" in navigator,
|
typeof navigator !== "undefined" && "share" in navigator,
|
||||||
);
|
);
|
||||||
let copySuccess = $state(false);
|
let copySuccess = $state(false);
|
||||||
|
let bubbleCopied = $state(false);
|
||||||
|
let copyTracked = $state(false);
|
||||||
|
let showSnippetOption = $state(false);
|
||||||
|
let includeSnippet = $state(false);
|
||||||
|
let showProgressDropdown = $state(false);
|
||||||
|
|
||||||
|
let effectiveShareText = $derived(
|
||||||
|
includeSnippet
|
||||||
|
? (() => {
|
||||||
|
const snippet = getVerseSnippet(verseText);
|
||||||
|
const lines = shareText.split("\n");
|
||||||
|
return [
|
||||||
|
...lines.slice(0, -1),
|
||||||
|
snippet,
|
||||||
|
lines[lines.length - 1],
|
||||||
|
].join("\n");
|
||||||
|
})()
|
||||||
|
: shareText,
|
||||||
|
);
|
||||||
|
|
||||||
// List of congratulations messages with weights
|
// List of congratulations messages with weights
|
||||||
const congratulationsMessages: WeightedMessage[] = [
|
const congratulationsMessages: WeightedMessage[] = [
|
||||||
@@ -66,9 +101,9 @@
|
|||||||
if (guessCount === 1) {
|
if (guessCount === 1) {
|
||||||
const n = Math.random();
|
const n = Math.random();
|
||||||
if (n < 0.99) {
|
if (n < 0.99) {
|
||||||
return "🌟 First try! 🌟";
|
return "First try!";
|
||||||
} else {
|
} else {
|
||||||
return "🗣️ Axios! 🗣️";
|
return "Axios!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,22 +130,27 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Container
|
<Container
|
||||||
class="w-full px-2 sm:px-4 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
|
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
|
||||||
>
|
>
|
||||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
<div class="flex flex-col gap-3">
|
||||||
{congratulationsMessage} The verse is from<br />
|
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
{congratulationsMessage} The verse is from
|
||||||
</p>
|
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||||
<p class="text-lg sm:text-xl md:text-2xl mt-2">
|
>{bookName}</span
|
||||||
You guessed correctly after {guessCount}
|
>.
|
||||||
{guessCount === 1 ? "guess" : "guesses"}.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if streak > 1}
|
|
||||||
<p class="big-text text-orange-500! text-sm! mt-4">
|
|
||||||
🔥 {streak} day streak!
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
<p class="text-lg sm:text-xl md:text-2xl">
|
||||||
|
You guessed correctly after {guessCount}
|
||||||
|
{guessCount === 1 ? "guess" : "guesses"}.
|
||||||
|
</p>
|
||||||
|
<!-- {#if streak >= 7}
|
||||||
|
<p
|
||||||
|
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||||
|
>
|
||||||
|
Thank you for making BIBDLE part of your daily routine!
|
||||||
|
</p>
|
||||||
|
{/if} -->
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<!-- S++ Bonus Challenge for first try -->
|
<!-- S++ Bonus Challenge for first try -->
|
||||||
@@ -122,12 +162,21 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CountdownTimer />
|
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||||
|
<div class="flex-2 min-w-0 flex flex-col">
|
||||||
|
<CountdownTimer />
|
||||||
|
</div>
|
||||||
|
{#if streak > 0}
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
|
<StreakCounter {streak} {streakPercentile} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Display -->
|
<!-- Statistics Display -->
|
||||||
{#if statsData}
|
{#if statsData}
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||||
@@ -136,7 +185,7 @@
|
|||||||
<!-- Solve Rank Column -->
|
<!-- Solve Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
#{statsData.solveRank}
|
#{statsData.solveRank}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +198,7 @@
|
|||||||
<!-- Guess Rank Column -->
|
<!-- Guess Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
{toOrdinal(statsData.guessRank)}
|
{toOrdinal(statsData.guessRank)}
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +220,7 @@
|
|||||||
<!-- Average Column -->
|
<!-- Average Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
{statsData.averageGuesses}
|
{statsData.averageGuesses}
|
||||||
</div>
|
</div>
|
||||||
@@ -185,41 +234,234 @@
|
|||||||
</Container>
|
</Container>
|
||||||
{:else if !statsSubmitted}
|
{:else if !statsSubmitted}
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||||
>
|
>
|
||||||
<div class="text-sm opacity-80">Submitting stats...</div>
|
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||||
</Container>
|
</Container>
|
||||||
{/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!">
|
||||||
<div class="share-buttons">
|
Share your result
|
||||||
{#if hasWebShare}
|
|
||||||
<button
|
|
||||||
onclick={handleShare}
|
|
||||||
data-umami-event="Share"
|
|
||||||
class="share-btn primary"
|
|
||||||
>
|
|
||||||
📤 Click to share
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
copyToClipboard();
|
|
||||||
copySuccess = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
copySuccess = false;
|
|
||||||
}, 3000);
|
|
||||||
}}
|
|
||||||
data-umami-event="Copy to Clipboard"
|
|
||||||
class={`share-btn primary ${copySuccess ? "success" : ""}`}
|
|
||||||
>
|
|
||||||
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-window">
|
<div class="chat-window">
|
||||||
<p class="bubble">{shareText}</p>
|
<!-- Received bubble: primary action (share / copy) -->
|
||||||
|
<div class="bubble-wrapper received-wrapper">
|
||||||
|
<button
|
||||||
|
class="bubble bubble-received"
|
||||||
|
class:success={copySuccess}
|
||||||
|
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
||||||
|
data-umami-event={hasWebShare
|
||||||
|
? "Share"
|
||||||
|
: "Copy to Clipboard"}
|
||||||
|
onclick={() => {
|
||||||
|
if (hasWebShare) {
|
||||||
|
shareResult(effectiveShareText);
|
||||||
|
} else {
|
||||||
|
if (!copyTracked) {
|
||||||
|
copyTracked = true;
|
||||||
|
}
|
||||||
|
clipboardCopy(effectiveShareText);
|
||||||
|
copySuccess = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copySuccess = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if hasWebShare}
|
||||||
|
📤 Tap here to share
|
||||||
|
{:else if copySuccess}
|
||||||
|
✅ Copied!
|
||||||
|
{:else}
|
||||||
|
📋 Copy to clipboard
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sent bubble: share text preview -->
|
||||||
|
<div class="bubble-wrapper">
|
||||||
|
<button
|
||||||
|
class="bubble bubble-sent"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
data-umami-event="Copy to Clipboard"
|
||||||
|
onclick={() => {
|
||||||
|
if (!copyTracked) {
|
||||||
|
copyTracked = true;
|
||||||
|
}
|
||||||
|
clipboardCopy(effectiveShareText);
|
||||||
|
showSnippetOption = true;
|
||||||
|
bubbleCopied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
bubbleCopied = false;
|
||||||
|
}, 2000);
|
||||||
|
}}>{effectiveShareText}</button
|
||||||
|
>
|
||||||
|
{#if hasWebShare}
|
||||||
|
<span class="copy-hint"
|
||||||
|
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="copy-hint"
|
||||||
|
>{bubbleCopied ? "copied!" : ""}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
{#if showSnippetOption}
|
||||||
|
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
||||||
|
<span class="snippet-label">Show verse snippet in share?</span>
|
||||||
|
<button
|
||||||
|
class="snippet-toggle"
|
||||||
|
class:on={includeSnippet}
|
||||||
|
onclick={() => (includeSnippet = !includeSnippet)}
|
||||||
|
aria-pressed={includeSnippet}
|
||||||
|
aria-label="Show snippet in share"
|
||||||
|
>
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoggedIn}
|
||||||
|
<div class="signin-prompt">
|
||||||
|
<a
|
||||||
|
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} />
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,14 +478,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
:global(.fade-in) {
|
||||||
animation: fadeIn 0.5s ease-out;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Share card ── */
|
/* ── Share card ── */
|
||||||
.share-card {
|
.share-card {
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: oklch(94% 0.028 298.626);
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 1.25rem;
|
border-radius: 1.25rem;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
@@ -256,6 +497,13 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.share-card {
|
||||||
|
background: oklch(22% 0.025 298.626);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.share-card::before {
|
.share-card::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -265,115 +513,454 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-card-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: #8e8e93;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Chat window ── */
|
/* ── Chat window ── */
|
||||||
.chat-window {
|
.chat-window {
|
||||||
--sent-color: #0b93f6;
|
--sent-color: #0b93f6;
|
||||||
--bg: oklch(93.996% 0.03041 300.209);
|
--received-color: #3a3a3c;
|
||||||
|
--bg: oklch(94% 0.028 298.626);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
padding: 0 1.5rem 0;
|
padding: 0 0.5rem 0;
|
||||||
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bubble (from article technique) ── */
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.chat-window {
|
||||||
|
--bg: oklch(22% 0.025 298.626);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bubble wrappers ── */
|
||||||
|
.bubble-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-wrapper {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared bubble base ── */
|
||||||
.bubble {
|
.bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 255px;
|
max-width: 255px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
transform: rotate(-2deg);
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
color: white;
|
filter 80ms ease,
|
||||||
background: var(--sent-color);
|
transform 80ms ease;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble::before,
|
/* ── Sent bubble (share text preview) ── */
|
||||||
.bubble::after {
|
.bubble-sent {
|
||||||
|
color: white;
|
||||||
|
background: var(--sent-color);
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:hover {
|
||||||
|
background-color: #2ea8ff;
|
||||||
|
transform: rotate(-2deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:hover::before {
|
||||||
|
background-color: #2ea8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:active {
|
||||||
|
background-color: #0878d4;
|
||||||
|
transform: rotate(-2deg) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:active::before {
|
||||||
|
background-color: #0878d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sent tail: bottom-right */
|
||||||
|
.bubble-sent::before,
|
||||||
|
.bubble-sent::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble::before {
|
.bubble-sent::before {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
right: -7px;
|
right: -7px;
|
||||||
background-color: var(--sent-color);
|
background-color: var(--sent-color);
|
||||||
border-bottom-left-radius: 16px 14px;
|
border-bottom-left-radius: 16px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble::after {
|
.bubble-sent::after {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
right: -26px;
|
right: -26px;
|
||||||
border-bottom-left-radius: 10px;
|
border-bottom-left-radius: 10px;
|
||||||
background-color: var(--bg);
|
background-color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Share buttons ── */
|
/* ── Received bubble (action button) ── */
|
||||||
.share-buttons {
|
.bubble-received {
|
||||||
display: flex;
|
color: #f5f5f7;
|
||||||
flex-direction: column;
|
background: var(--received-color);
|
||||||
gap: 0.5rem;
|
transform: rotate(2deg);
|
||||||
width: 100%;
|
padding: 14px 24px;
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border: 3px solid rgba(0, 0, 0, 0.25);
|
min-width: 14rem;
|
||||||
border-radius: 1rem;
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:hover {
|
||||||
|
background-color: #4a4a4e;
|
||||||
|
transform: rotate(2deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:hover::before {
|
||||||
|
background-color: #4a4a4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:active {
|
||||||
|
background-color: #2a2a2c;
|
||||||
|
transform: rotate(2deg) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:active::before {
|
||||||
|
background-color: #2a2a2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received.success {
|
||||||
|
background: #c7f7d4;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Received tail: bottom-left (mirror of sent) */
|
||||||
|
.bubble-received::before,
|
||||||
|
.bubble-received::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
height: 25px;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received::before {
|
||||||
|
width: 20px;
|
||||||
|
left: -7px;
|
||||||
|
background-color: var(--received-color);
|
||||||
|
border-bottom-right-radius: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received::after {
|
||||||
|
width: 26px;
|
||||||
|
left: -26px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received.success::before {
|
||||||
|
background-color: #c7f7d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Copy hints ── */
|
||||||
|
.copy-hint {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #444;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
padding-right: 32px;
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
transform-origin: right center;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.copy-hint {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Snippet toggle row ── */
|
||||||
|
.snippet-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #666;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.snippet-label {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ccc;
|
||||||
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background 200ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-toggle.on {
|
||||||
|
background: #34c759;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-toggle.on .toggle-thumb {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
/*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:
|
transition:
|
||||||
transform 80ms ease,
|
transform 80ms ease,
|
||||||
box-shadow 80ms ease,
|
box-shadow 80ms ease,
|
||||||
opacity 80ms ease;
|
background-color 120ms ease;
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-btn:active {
|
.discord-btn:hover {
|
||||||
transform: scale(0.97);
|
transform: translate(-2px, -2px);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
box-shadow: 8px 8px 0 0 #000;
|
||||||
|
background: #4752c4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-btn.primary {
|
.discord-btn:active {
|
||||||
background: #7c3aed;
|
transform: translate(2px, 2px);
|
||||||
color: white;
|
box-shadow: 2px 2px 0 0 #000;
|
||||||
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.4);
|
background: #3c45a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-btn.primary:hover {
|
@media (prefers-color-scheme: dark) {
|
||||||
opacity: 0.92;
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-btn.primary.success {
|
.discord-icon {
|
||||||
background: #636363;
|
width: 1.25rem;
|
||||||
color: white;
|
height: 1.25rem;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-btn.secondary {
|
.signin-text {
|
||||||
background: #f2f2f7;
|
font-size: 0.85rem;
|
||||||
color: #1c1c1e;
|
text-align: center;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-btn.secondary:hover {
|
.apple-signin-btn {
|
||||||
background: #e5e5ea;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 150ms ease,
|
||||||
|
transform 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-signin-btn:hover {
|
||||||
|
background: #222;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-signin-btn:active {
|
||||||
|
background: #111;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.apple-signin-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.apple-signin-btn:hover {
|
||||||
|
background: #e5e5e5;
|
||||||
|
}
|
||||||
|
.apple-signin-btn:active {
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { encodeBase64url } from '@oslojs/encoding';
|
|
||||||
|
|
||||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||||
|
|
||||||
@@ -26,8 +24,8 @@ export async function generateAppleClientSecret(): Promise<string> {
|
|||||||
sub: Bun.env.APPLE_ID!
|
sub: Bun.env.APPLE_ID!
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||||
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
|
||||||
// Import PEM private key
|
// Import PEM private key
|
||||||
@@ -55,7 +53,7 @@ export async function generateAppleClientSecret(): Promise<string> {
|
|||||||
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||||
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||||
const encodedSignature = encodeBase64url(rawSignature);
|
const encodedSignature = Buffer.from(rawSignature).toString('base64url');
|
||||||
|
|
||||||
return `${signingInput}.${encodedSignature}`;
|
return `${signingInput}.${encodedSignature}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sha256 } from '@oslojs/crypto/sha2';
|
|
||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
|
||||||
import { testDb as db } from '$lib/server/db/test';
|
import { testDb as db } from '$lib/server/db/test';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
|
|
||||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
|||||||
|
|
||||||
export function generateSessionToken() {
|
export function generateSessionToken() {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
const token = encodeBase64url(bytes);
|
return Buffer.from(bytes).toString('base64url');
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(token: string, userId: string) {
|
export async function createSession(token: string, userId: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const session: table.Session = {
|
const session: table.Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(token: string) {
|
export async function validateSessionToken(token: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// Adjust user table here to tweak returned data
|
||||||
@@ -67,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: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -102,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
|
||||||
|
|||||||
+15
-12
@@ -1,7 +1,5 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { Cookies, RequestEvent } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sha256 } from '@oslojs/crypto/sha2';
|
|
||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
|
||||||
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';
|
||||||
|
|
||||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
|||||||
|
|
||||||
export function generateSessionToken() {
|
export function generateSessionToken() {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
const token = encodeBase64url(bytes);
|
return Buffer.from(bytes).toString('base64url');
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(token: string, userId: string) {
|
export async function createSession(token: string, userId: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const session: table.Session = {
|
const session: table.Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -27,11 +24,11 @@ export async function createSession(token: string, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(token: string) {
|
export async function validateSessionToken(token: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const [result] = await db
|
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)
|
||||||
@@ -67,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: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -102,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
|
||||||
@@ -120,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'> = {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export const user = sqliteTable('user', {
|
export const user = sqliteTable('user', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -8,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)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { DailyVerse } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feed presents calendar days, so every timestamp is anchored to midnight
|
||||||
|
* in this reference timezone. Using a fixed zone (rather than the server's
|
||||||
|
* local timezone) keeps output stable across UTC dev machines and NY hosts.
|
||||||
|
*/
|
||||||
|
export const FEED_TIMEZONE = 'America/New_York';
|
||||||
|
|
||||||
|
/** Number of most-recent verses exposed in the feed. */
|
||||||
|
export const FEED_ITEM_LIMIT = 30;
|
||||||
|
|
||||||
|
/** Escape the five XML predefined entities. */
|
||||||
|
export function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current calendar date (`YYYY-MM-DD`) in the feed's reference timezone.
|
||||||
|
* Used to cap the feed so future/scheduled verses are never published early.
|
||||||
|
*/
|
||||||
|
export function todayInTimeZone(timeZone: string = FEED_TIMEZONE, now: Date = new Date()): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}).formatToParts(now);
|
||||||
|
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UTC offset in effect on a given `YYYY-MM-DD` for a timezone, formatted
|
||||||
|
* as `±HH:MM` (e.g. `-04:00` during EDT, `-05:00` during EST).
|
||||||
|
*/
|
||||||
|
export function timeZoneOffset(dateStr: string, timeZone: string = FEED_TIMEZONE): string {
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
// Noon UTC is safely inside the calendar day in any US offset, so DST is
|
||||||
|
// resolved correctly regardless of the time of day.
|
||||||
|
const reference = new Date(Date.UTC(year, month - 1, day, 12));
|
||||||
|
const name = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
timeZoneName: 'longOffset'
|
||||||
|
})
|
||||||
|
.formatToParts(reference)
|
||||||
|
.find((part) => part.type === 'timeZoneName')?.value;
|
||||||
|
|
||||||
|
const offset = (name ?? 'GMT+00:00').replace('GMT', '');
|
||||||
|
return offset === '' ? '+00:00' : offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a `YYYY-MM-DD` day as an RFC 822 / RFC 2822 timestamp at midnight in
|
||||||
|
* the feed's reference timezone. Always emits a valid numeric/GMT timestamp
|
||||||
|
* (never a bare, ambiguous abbreviation like `EST`).
|
||||||
|
*/
|
||||||
|
export function formatRFC822(dateStr: string, timeZone: string = FEED_TIMEZONE): string {
|
||||||
|
const offset = timeZoneOffset(dateStr, timeZone);
|
||||||
|
return new Date(`${dateStr}T00:00:00${offset}`).toUTCString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable date that does not shift with the server's local timezone. */
|
||||||
|
export function formatReadableDate(dateStr: string): string {
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
return new Date(Date.UTC(year, month - 1, day)).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap text in a CDATA section, splitting any embedded `]]>` safely. */
|
||||||
|
export function cdata(text: string): string {
|
||||||
|
return `<![CDATA[${text.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
||||||
|
export function formatVerseText(text: string): string {
|
||||||
|
let formatted = text;
|
||||||
|
|
||||||
|
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
||||||
|
const pairs: [string, string][] = [
|
||||||
|
['(', ')'],
|
||||||
|
['[', ']'],
|
||||||
|
['{', '}'],
|
||||||
|
['"', '"'],
|
||||||
|
["'", "'"],
|
||||||
|
['\u201C', '\u201D'], // " "
|
||||||
|
['\u2018', '\u2019'] // ' '
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if text starts with opening punctuation without closing
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||||
|
formatted += '...' + close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if text ends with closing punctuation without opening
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||||
|
formatted = open + '...' + formatted;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if text contains unbalanced opening quotes (not at start) without closing
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
||||||
|
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
||||||
|
if (openCount > closeCount) {
|
||||||
|
formatted += close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
||||||
|
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
// Replace trailing punctuation with ellipsis
|
||||||
|
// Preserve closing quotes/brackets that may have been added
|
||||||
|
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content fingerprint used as a strong ETag. Any change to the included rows
|
||||||
|
* (new day, edited verse text, changed reference) produces a new hash, so
|
||||||
|
* cached clients revalidate correctly.
|
||||||
|
*/
|
||||||
|
export function feedFingerprint(
|
||||||
|
verses: Pick<DailyVerse, 'date' | 'reference' | 'verseText'>[]
|
||||||
|
): string {
|
||||||
|
let hash = 5381;
|
||||||
|
for (const verse of verses) {
|
||||||
|
const row = `${verse.date}\u0000${verse.reference}\u0000${verse.verseText}`;
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + row.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent `createdAt` across the given rows, used for `Last-Modified`.
|
||||||
|
* Falls back to `fallback` when no row carries a timestamp.
|
||||||
|
*/
|
||||||
|
export function latestModification(
|
||||||
|
verses: Pick<DailyVerse, 'createdAt'>[],
|
||||||
|
fallback: Date
|
||||||
|
): Date {
|
||||||
|
return verses.reduce<Date | null>((latest, verse) => {
|
||||||
|
if (!verse.createdAt) return latest;
|
||||||
|
return !latest || verse.createdAt > latest ? verse.createdAt : latest;
|
||||||
|
}, null) ?? fallback;
|
||||||
|
}
|
||||||
@@ -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
@@ -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:31–2: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}`;
|
||||||
|
}
|
||||||
@@ -17,11 +17,18 @@ function getOrCreateAnonymousId(): string {
|
|||||||
// Reactive store that keeps in-memory game state in sync with localStorage.
|
// Reactive store that keeps in-memory game state in sync with localStorage.
|
||||||
// Accepts getter functions (rather than plain values) so Svelte's reactivity
|
// Accepts getter functions (rather than plain values) so Svelte's reactivity
|
||||||
// system can track dependencies and re-run effects when they change.
|
// system can track dependencies and re-run effects when they change.
|
||||||
|
type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function createGamePersistence(
|
export function createGamePersistence(
|
||||||
getDate: () => string,
|
getDate: () => string,
|
||||||
getReference: () => string,
|
getReference: () => string,
|
||||||
getCorrectBookId: () => string,
|
getCorrectBookId: () => string,
|
||||||
getUserId: () => string | undefined,
|
getUser: () => AuthUser | null | undefined,
|
||||||
) {
|
) {
|
||||||
let guesses = $state<Guess[]>([]);
|
let guesses = $state<Guess[]>([]);
|
||||||
let anonymousId = $state("");
|
let anonymousId = $state("");
|
||||||
@@ -34,19 +41,20 @@ export function createGamePersistence(
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
const userId = getUserId();
|
const user = getUser();
|
||||||
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||||
if (userId) {
|
if (user) {
|
||||||
anonymousId = userId;
|
anonymousId = user.id;
|
||||||
} else {
|
} else {
|
||||||
anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell Umami analytics which player this is so events are grouped correctly.
|
// Tell analytics which player this is so events are grouped correctly.
|
||||||
if ((window as any).umami) {
|
if ((window as any).umami) {
|
||||||
(window as any).umami.identify(anonymousId);
|
(window as any).umami.identify(anonymousId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const date = getDate();
|
const date = getDate();
|
||||||
const reference = getReference();
|
const reference = getReference();
|
||||||
|
|
||||||
|
|||||||
+66
-68
@@ -17,75 +17,73 @@ export interface BibleBook {
|
|||||||
testament: Testament;
|
testament: Testament;
|
||||||
section: BibleSection;
|
section: BibleSection;
|
||||||
order: number;
|
order: number;
|
||||||
url: string;
|
|
||||||
popularity: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bibleBooks: BibleBook[] = [
|
export const bibleBooks: BibleBook[] = [
|
||||||
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1, url: 'https://bible-api.com/data/web/GEN', popularity: 8 },
|
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
|
||||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
|
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
|
||||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
|
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
|
||||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
|
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
|
||||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
|
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
|
||||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
|
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
|
||||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
|
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
|
||||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
|
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
|
||||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
|
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
|
||||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
|
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
|
||||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
|
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
|
||||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
|
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
|
||||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
|
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
|
||||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
|
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
|
||||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
|
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
|
||||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
|
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
|
||||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
|
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
|
||||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
|
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
|
||||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
|
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
|
||||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
|
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
|
||||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
|
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
|
||||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
|
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
|
||||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
|
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
|
||||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
|
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
|
||||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
|
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
|
||||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
|
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
|
||||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
|
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
|
||||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
|
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
|
||||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
|
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
|
||||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
|
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
|
||||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
|
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
|
||||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
|
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
|
||||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
|
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
|
||||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
|
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
|
||||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
|
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
|
||||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
|
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
|
||||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
|
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
|
||||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
|
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
|
||||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
|
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
|
||||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
|
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
|
||||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
|
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
|
||||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
|
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
|
||||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
|
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
|
||||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
|
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
|
||||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
|
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
|
||||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
|
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
|
||||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
|
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
|
||||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
|
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
|
||||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
|
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
|
||||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
|
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
|
||||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
|
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
|
||||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
|
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
|
||||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
|
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
|
||||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
|
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
|
||||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
|
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
|
||||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
|
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
|
||||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
|
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
|
||||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
|
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
|
||||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
|
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
|
||||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
|
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
|
||||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
|
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
|
||||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
|
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
|
||||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
|
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
|
||||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
|
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
|
||||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
|
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
|
||||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
|
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66 }
|
||||||
];
|
];
|
||||||
@@ -79,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toOrdinal(n: number): string {
|
export function toOrdinal(n: number): string {
|
||||||
if (n >= 11 && n <= 13) {
|
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||||
return `${n}th`;
|
return `${n}th`;
|
||||||
}
|
}
|
||||||
const mod = n % 10;
|
const mod = n % 10;
|
||||||
|
|||||||
+43
-8
@@ -1,5 +1,42 @@
|
|||||||
import type { Guess } from './game';
|
import type { Guess } from './game';
|
||||||
|
|
||||||
|
export function getVerseSnippet(verseText: string): string {
|
||||||
|
const words = verseText.trim().split(/\s+/);
|
||||||
|
const slice = words.slice(0, 25);
|
||||||
|
const text = slice.join(' ');
|
||||||
|
|
||||||
|
// Returns character index immediately after the Nth word (1-indexed)
|
||||||
|
function posAfterWord(n: number): number {
|
||||||
|
let pos = 0;
|
||||||
|
for (let w = 0; w < Math.min(n, slice.length); w++) {
|
||||||
|
if (w > 0) pos++; // space between words
|
||||||
|
pos += slice[w].length;
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = posAfterWord(9);
|
||||||
|
const end = posAfterWord(25);
|
||||||
|
|
||||||
|
// Find first punctuation mark between words 10 and 25
|
||||||
|
const range = text.substring(start, end);
|
||||||
|
const match = range.match(/[,;:.!?—–-]/);
|
||||||
|
|
||||||
|
function withClosedQuotes(snippet: string): string {
|
||||||
|
const opens = (snippet.match(/\u201C/g) ?? []).length;
|
||||||
|
const closes = (snippet.match(/\u201D/g) ?? []).length;
|
||||||
|
const closeQuote = opens > closes ? '\u201D' : '';
|
||||||
|
return `\u201C${snippet}...${closeQuote}\u201D`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
const cutPos = start + match.index;
|
||||||
|
return withClosedQuotes(text.substring(0, cutPos).trimEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
return withClosedQuotes(text);
|
||||||
|
}
|
||||||
|
|
||||||
export function generateShareText(params: {
|
export function generateShareText(params: {
|
||||||
guesses: Guess[];
|
guesses: Guess[];
|
||||||
correctBookId: string;
|
correctBookId: string;
|
||||||
@@ -8,8 +45,9 @@ export function generateShareText(params: {
|
|||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
streak?: number;
|
streak?: number;
|
||||||
origin: string;
|
origin: string;
|
||||||
|
verseText: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin } = params;
|
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
|
||||||
|
|
||||||
const emojis = guesses
|
const emojis = guesses
|
||||||
.slice()
|
.slice()
|
||||||
@@ -35,18 +73,15 @@ export function generateShareText(params: {
|
|||||||
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
||||||
|
|
||||||
const guessWord = guesses.length === 1 ? "guess" : "guesses";
|
const guessWord = guesses.length === 1 ? "guess" : "guesses";
|
||||||
const streakPart = streak !== undefined && streak > 1 ? `, ${streak} days 🔥` : "";
|
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
|
||||||
|
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||||
`${guesses.length} ${guessWord}${streakPart}`,
|
`${guesses.length} ${guessWord}${streakPart ? `,${streakPart}` : ""}`,
|
||||||
|
`${emojis}${chapterStar}`
|
||||||
];
|
];
|
||||||
|
|
||||||
lines.push(
|
|
||||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
|
||||||
origin,
|
|
||||||
);
|
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ export async function fetchStreak(anonymousId: string, localDate: string): Promi
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return typeof data.streak === 'number' ? data.streak : 0;
|
return typeof data.streak === 'number' ? data.streak : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchStreakPercentile(streak: number, localDate: string): Promise<number | null> {
|
||||||
|
const params = new URLSearchParams({ streak: String(streak), localDate });
|
||||||
|
const res = await fetch(`/api/streak-percentile?${params}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return typeof data.percentile === 'number' ? data.percentile : null;
|
||||||
|
}
|
||||||
+44
-18
@@ -1,26 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import { browser } from '$app/environment';
|
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 ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
|
||||||
onMount(() => {
|
let isDev = $state(false);
|
||||||
if (browser) {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.defer = true;
|
|
||||||
script.src = 'https://umami.snail.city/script.js';
|
|
||||||
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
|
||||||
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
|
||||||
document.body.appendChild(script);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let { children } = $props();
|
onMount(() => {
|
||||||
|
isDev =
|
||||||
|
window.location.host === "localhost:5173" ||
|
||||||
|
window.location.host === "test.bibdle.com";
|
||||||
|
|
||||||
|
// Inject analytics script
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.defer = true;
|
||||||
|
script.src = "https://umami.snail.city/script.js";
|
||||||
|
script.setAttribute(
|
||||||
|
"data-website-id",
|
||||||
|
"5b8c31ad-71cd-4317-940b-6bccea732acc",
|
||||||
|
);
|
||||||
|
script.setAttribute("data-domains", "bibdle.com,www.bibdle.com");
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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" />
|
<meta name="description" content="A Wordle-inspired daily Bible game" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
{@render children()}
|
|
||||||
|
<div
|
||||||
|
class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
|
||||||
|
>
|
||||||
|
<TitleAnimation />
|
||||||
|
<div class="font-normal"></div>
|
||||||
|
</h1>
|
||||||
|
{#if isDev}
|
||||||
|
<div class="flex justify-center pb-2"><ThemeToggle /></div>
|
||||||
|
{:else}
|
||||||
|
<div class="justify-center hidden pb-2"><ThemeToggle /></div>
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
+172
-72
@@ -2,29 +2,32 @@
|
|||||||
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 TitleAnimation from "$lib/components/TitleAnimation.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,
|
||||||
copyToClipboard as clipboardCopy,
|
copyToClipboard as clipboardCopy,
|
||||||
} from "$lib/utils/share";
|
} from "$lib/utils/share";
|
||||||
import { fetchStreak } from "$lib/utils/streak";
|
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
|
||||||
import {
|
import {
|
||||||
submitCompletion,
|
submitCompletion,
|
||||||
fetchExistingStats,
|
fetchExistingStats,
|
||||||
type StatsData,
|
type StatsData,
|
||||||
} from "$lib/utils/stats-client";
|
} from "$lib/utils/stats-client";
|
||||||
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
@@ -34,25 +37,6 @@
|
|||||||
let user = $derived(data.user);
|
let user = $derived(data.user);
|
||||||
let session = $derived(data.session);
|
let session = $derived(data.session);
|
||||||
|
|
||||||
let searchQuery = $state("");
|
|
||||||
let copied = $state(false);
|
|
||||||
let isDev = $state(false);
|
|
||||||
let authModalOpen = $state(false);
|
|
||||||
let showWinScreen = $state(false);
|
|
||||||
let statsData = $state<StatsData | null>(null);
|
|
||||||
let streak = $state(0);
|
|
||||||
|
|
||||||
const persistence = createGamePersistence(
|
|
||||||
() => dailyVerse.date,
|
|
||||||
() => dailyVerse.reference,
|
|
||||||
() => correctBookId,
|
|
||||||
() => user?.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
let guessedIds = $derived(
|
|
||||||
new Set(persistence.guesses.map((g) => g.book.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentDate = $derived(
|
const currentDate = $derived(
|
||||||
new Date().toLocaleDateString("en-US", {
|
new Date().toLocaleDateString("en-US", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
@@ -62,6 +46,27 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let searchQuery = $state("");
|
||||||
|
let copied = $state(false);
|
||||||
|
let isDev = $state(false);
|
||||||
|
let authModalOpen = $state(false);
|
||||||
|
let showWinScreen = $state(false);
|
||||||
|
let statsData = $state<StatsData | null>(null);
|
||||||
|
let streak = $state(0);
|
||||||
|
let streakPercentile = $state<number | null>(null);
|
||||||
|
let guessesMinimized = $state(false);
|
||||||
|
|
||||||
|
const persistence = createGamePersistence(
|
||||||
|
() => dailyVerse.date,
|
||||||
|
() => dailyVerse.reference,
|
||||||
|
() => correctBookId,
|
||||||
|
() => user,
|
||||||
|
);
|
||||||
|
|
||||||
|
let guessedIds = $derived(
|
||||||
|
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
|
||||||
|
);
|
||||||
|
|
||||||
let isWon = $derived(
|
let isWon = $derived(
|
||||||
persistence.guesses.some((g) => g.book.id === correctBookId),
|
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||||
);
|
);
|
||||||
@@ -71,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;
|
||||||
|
|
||||||
@@ -200,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;
|
||||||
@@ -217,6 +295,11 @@
|
|||||||
const localDate = new Date().toLocaleDateString("en-CA");
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||||
streak = result;
|
streak = result;
|
||||||
|
if (result >= 2) {
|
||||||
|
fetchStreakPercentile(result, localDate).then((p) => {
|
||||||
|
streakPercentile = p;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,6 +312,7 @@
|
|||||||
isLoggedIn: !!user,
|
isLoggedIn: !!user,
|
||||||
streak,
|
streak,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
|
verseText: dailyVerse.verseText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,20 +355,13 @@
|
|||||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
|
<div class="pb-8">
|
||||||
<div class="w-full max-w-3xl mx-auto px-4">
|
<div class="w-full max-w-3xl mx-auto px-4">
|
||||||
<h1
|
|
||||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4 animate-fade-in-up"
|
|
||||||
>
|
|
||||||
<TitleAnimation />
|
|
||||||
<div class="font-normal"></div>
|
|
||||||
</h1>
|
|
||||||
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||||
<span class="big-text"
|
<span class="big-text"
|
||||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="animate-fade-in-up animate-delay-200">
|
<div class="animate-fade-in-up animate-delay-200">
|
||||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||||
@@ -292,7 +369,50 @@
|
|||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<div class="animate-fade-in-up animate-delay-400">
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<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
|
||||||
|
bind:searchQuery
|
||||||
|
{guessedIds}
|
||||||
|
{submitGuess}
|
||||||
|
guessCount={persistence.guesses.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if showWinScreen}
|
{:else if showWinScreen}
|
||||||
<div class="animate-fade-in-up animate-delay-400">
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
@@ -307,16 +427,28 @@
|
|||||||
reference={dailyVerse.reference}
|
reference={dailyVerse.reference}
|
||||||
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||||
shareText={getShareText()}
|
shareText={getShareText()}
|
||||||
|
verseText={dailyVerse.verseText}
|
||||||
{streak}
|
{streak}
|
||||||
|
{streakPercentile}
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
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>
|
||||||
@@ -324,44 +456,8 @@
|
|||||||
</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">
|
||||||
<div class="flex flex-col md:flex-row gap-3">
|
|
||||||
<a
|
|
||||||
href="/stats?{user
|
|
||||||
? `userId=${user.id}`
|
|
||||||
: `anonymousId=${persistence.anonymousId}`}&tz={encodeURIComponent(
|
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
||||||
)}"
|
|
||||||
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
|
||||||
>
|
|
||||||
📊 View Stats
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{#if user}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="/auth/logout"
|
|
||||||
use:enhance
|
|
||||||
class="w-full md:w-auto"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
|
||||||
>
|
|
||||||
🚪 Sign Out
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={() => (authModalOpen = true)}
|
|
||||||
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
|
||||||
>
|
|
||||||
🔐 Sign In
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded border"
|
class="text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded border dark:border-gray-700"
|
||||||
>
|
>
|
||||||
<div><strong>Debug Info:</strong></div>
|
<div><strong>Debug Info:</strong></div>
|
||||||
<div>
|
<div>
|
||||||
@@ -393,19 +489,23 @@
|
|||||||
<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} />
|
<DevButtons
|
||||||
|
anonymousId={persistence.anonymousId}
|
||||||
|
{user}
|
||||||
|
onSignIn={() => (authModalOpen = true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if user && session}
|
{#if user && session}
|
||||||
<div
|
<div
|
||||||
class="mt-6 pt-4 border-t border-gray-200 text-center text-xs text-gray-400"
|
class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center text-xs text-gray-400 dark:text-gray-500"
|
||||||
>
|
>
|
||||||
Signed in as {[user.firstName, user.lastName]
|
Signed in as {[user.firstName, user.lastName]
|
||||||
.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"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
const about = readFileSync(resolve('static/about.md'), 'utf-8');
|
||||||
|
const howToPlay = readFileSync(resolve('static/how-to-play.md'), 'utf-8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
about: await marked(about),
|
||||||
|
howToPlay: await marked(howToPlay)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<svelte:head>
|
||||||
|
<title>About — Bibdle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const SOCIAL_PLACEHOLDER = "<!-- social -->";
|
||||||
|
|
||||||
|
const aboutParts = $derived(
|
||||||
|
data.about.includes(SOCIAL_PLACEHOLDER)
|
||||||
|
? data.about.split(SOCIAL_PLACEHOLDER)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-dvh py-10 px-4">
|
||||||
|
<div class="w-full max-w-xl mx-auto">
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose dark:prose-invert text-justify max-w-none">
|
||||||
|
{#if aboutParts}
|
||||||
|
{@html aboutParts[0]}
|
||||||
|
<div class="my-8 not-prose">
|
||||||
|
<SocialLinks />
|
||||||
|
</div>
|
||||||
|
{@html aboutParts[1]}
|
||||||
|
{:else}
|
||||||
|
{@html data.about}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose dark:prose-invert text-justify max-w-none mt-10">
|
||||||
|
{@html data.howToPlay}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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());
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { anonymousId } = await request.json();
|
const { anonymousId, days = 10 } = await request.json();
|
||||||
|
|
||||||
if (!anonymousId || typeof anonymousId !== 'string') {
|
if (!anonymousId || typeof anonymousId !== 'string') {
|
||||||
return json({ error: 'anonymousId required' }, { status: 400 });
|
return json({ error: 'anonymousId required' }, { status: 400 });
|
||||||
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
const inserted: string[] = [];
|
const inserted: string[] = [];
|
||||||
const skipped: string[] = [];
|
const skipped: string[] = [];
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= days; i++) {
|
||||||
const d = new Date(today);
|
const d = new Date(today);
|
||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const streakParam = url.searchParams.get('streak');
|
||||||
|
const localDate = url.searchParams.get('localDate');
|
||||||
|
|
||||||
|
if (!streakParam || !localDate) {
|
||||||
|
error(400, 'Missing streak or localDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStreak = parseInt(streakParam, 10);
|
||||||
|
if (isNaN(targetStreak) || targetStreak < 1) {
|
||||||
|
error(400, 'Invalid streak');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all completions ordered by anonymous_id and date desc
|
||||||
|
// so we can walk each user's history to compute their current streak.
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
|
date: dailyCompletions.date,
|
||||||
|
})
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
// Group dates by user
|
||||||
|
const byUser = new Map<string, string[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byUser.get(row.anonymousId);
|
||||||
|
if (list) {
|
||||||
|
list.push(row.date);
|
||||||
|
} else {
|
||||||
|
byUser.set(row.anonymousId, [row.date]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the current streak for each user.
|
||||||
|
// Start from today; if the user hasn't played today yet, try yesterday so
|
||||||
|
// that streaks aren't zeroed out mid-day before the player has had a chance
|
||||||
|
// to complete today's puzzle.
|
||||||
|
const yesterday = new Date(`${localDate}T00:00:00`);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayStr = yesterday.toLocaleDateString('en-CA');
|
||||||
|
|
||||||
|
const thirtyDaysAgo = new Date(`${localDate}T00:00:00`);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
const thirtyDaysAgoStr = thirtyDaysAgo.toLocaleDateString('en-CA');
|
||||||
|
|
||||||
|
// For each user, compute their current streak and whether they've played
|
||||||
|
// within the last 30 days. "Eligible players" = active streak OR recent play.
|
||||||
|
const userStats: { streak: number; isEligible: boolean }[] = [];
|
||||||
|
for (const [, dates] of byUser) {
|
||||||
|
// dates are already desc-sorted
|
||||||
|
const dateSet = new Set(dates);
|
||||||
|
|
||||||
|
// Pick the most recent anchor: today if played, otherwise yesterday
|
||||||
|
const anchor = dateSet.has(localDate) ? localDate : yesterdayStr;
|
||||||
|
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date(`${anchor}T00:00:00`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const dateStr = cursor.toLocaleDateString('en-CA');
|
||||||
|
if (!dateSet.has(dateStr)) break;
|
||||||
|
streak++;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRecentPlay = dates.some((d) => d >= thirtyDaysAgoStr);
|
||||||
|
userStats.push({ streak, isEligible: streak >= 1 || hasRecentPlay });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligiblePlayers = userStats.filter((u) => u.isEligible);
|
||||||
|
|
||||||
|
if (eligiblePlayers.length === 0) {
|
||||||
|
console.log('[streak-percentile] No eligible players found, returning 100th percentile');
|
||||||
|
return json({ percentile: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentage of eligible players who have a streak >= targetStreak
|
||||||
|
const atOrAbove = eligiblePlayers.filter((u) => u.streak >= targetStreak).length;
|
||||||
|
const raw = (atOrAbove / eligiblePlayers.length) * 100;
|
||||||
|
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
|
||||||
|
|
||||||
|
console.log('[streak-percentile]', {
|
||||||
|
localDate,
|
||||||
|
targetStreak,
|
||||||
|
totalUsers: byUser.size,
|
||||||
|
totalRows: rows.length,
|
||||||
|
eligiblePlayers: eligiblePlayers.length,
|
||||||
|
activeStreaks: userStats.filter((u) => u.streak >= 1).length,
|
||||||
|
recentPlayers: userStats.filter((u) => u.isEligible).length,
|
||||||
|
atOrAbove,
|
||||||
|
raw,
|
||||||
|
percentile,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ percentile });
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
error(400, 'Missing anonymousId or localDate');
|
error(400, 'Missing anonymousId or localDate');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all completion dates for this user, newest first
|
// Fetch all completion dates for this user (stored as the user's local date)
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ date: dailyCompletions.date })
|
.select({ date: dailyCompletions.date })
|
||||||
.from(dailyCompletions)
|
.from(dailyCompletions)
|
||||||
@@ -21,16 +21,22 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
|
|
||||||
const completedDates = new Set(rows.map((r) => r.date));
|
const completedDates = new Set(rows.map((r) => r.date));
|
||||||
|
|
||||||
// Walk backwards from localDate, counting consecutive completed days
|
// Subtract one calendar day from a YYYY-MM-DD string using UTC arithmetic —
|
||||||
let streak = 0;
|
// this avoids any dependence on the server's local timezone or DST offsets.
|
||||||
let cursor = new Date(`${localDate}T00:00:00`);
|
function prevDay(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
while (true) {
|
d.setUTCDate(d.getUTCDate() - 1);
|
||||||
const dateStr = cursor.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
return d.toISOString().slice(0, 10);
|
||||||
if (!completedDates.has(dateStr)) break;
|
|
||||||
streak++;
|
|
||||||
cursor.setDate(cursor.getDate() - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ streak });
|
// Walk backwards from the user's local date, counting consecutive completed days
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = localDate;
|
||||||
|
|
||||||
|
while (completedDates.has(cursor)) {
|
||||||
|
streak++;
|
||||||
|
cursor = prevDay(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ streak: streak < 2 ? 0 : streak });
|
||||||
};
|
};
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5–7. 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
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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,7 +1,6 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ cookies, request }) => {
|
default: async ({ cookies, request }) => {
|
||||||
@@ -10,7 +9,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
// Generate CSRF state
|
// Generate CSRF state
|
||||||
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const state = encodeBase64url(stateBytes);
|
const state = Buffer.from(stateBytes).toString('base64url');
|
||||||
|
|
||||||
// Store state + anonymousId in a short-lived cookie
|
// Store state + anonymousId in a short-lived cookie
|
||||||
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||||
|
|||||||
@@ -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, '/');
|
||||||
};
|
};
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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, '/');
|
||||||
|
};
|
||||||
+69
-102
@@ -1,133 +1,99 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
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 { desc } from 'drizzle-orm';
|
import { desc, lte } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
// Helper: Escape XML special characters
|
FEED_ITEM_LIMIT,
|
||||||
function escapeXml(text: string): string {
|
cdata,
|
||||||
return text
|
escapeXml,
|
||||||
.replace(/&/g, '&')
|
feedFingerprint,
|
||||||
.replace(/</g, '<')
|
formatRFC822,
|
||||||
.replace(/>/g, '>')
|
formatReadableDate,
|
||||||
.replace(/"/g, '"')
|
formatVerseText,
|
||||||
.replace(/'/g, ''');
|
latestModification,
|
||||||
}
|
todayInTimeZone
|
||||||
|
} from '$lib/server/rss';
|
||||||
// Helper: Format YYYY-MM-DD to RFC 822 date string
|
|
||||||
function formatRFC822(dateStr: string): string {
|
|
||||||
// Parse date in America/New_York timezone (EST/EDT)
|
|
||||||
// Assuming midnight ET
|
|
||||||
const date = new Date(dateStr + 'T00:00:00-05:00');
|
|
||||||
return date.toUTCString().replace('GMT', 'EST');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Format YYYY-MM-DD to readable date
|
|
||||||
function formatReadableDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr + 'T00:00:00');
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: 'America/New_York'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
|
||||||
function formatVerseText(text: string): string {
|
|
||||||
let formatted = text;
|
|
||||||
|
|
||||||
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
|
||||||
const pairs: [string, string][] = [
|
|
||||||
['(', ')'],
|
|
||||||
['[', ']'],
|
|
||||||
['{', '}'],
|
|
||||||
['"', '"'],
|
|
||||||
["'", "'"],
|
|
||||||
['\u201C', '\u201D'], // " "
|
|
||||||
['\u2018', '\u2019'] // ' '
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check if text starts with opening punctuation without closing
|
|
||||||
for (const [open, close] of pairs) {
|
|
||||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
|
||||||
formatted += '...' + close;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if text ends with closing punctuation without opening
|
|
||||||
for (const [open, close] of pairs) {
|
|
||||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
|
||||||
formatted = open + '...' + formatted;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if text contains unbalanced opening quotes (not at start) without closing
|
|
||||||
for (const [open, close] of pairs) {
|
|
||||||
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
|
||||||
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
|
||||||
if (openCount > closeCount) {
|
|
||||||
formatted += close;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
|
||||||
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
// Replace trailing punctuation with ellipsis
|
|
||||||
// Preserve closing quotes/brackets that may have been added
|
|
||||||
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
|
||||||
|
|
||||||
return formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ request }) => {
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
// Query last 30 verses, ordered by date descending
|
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
|
||||||
|
const FEED_URL = `${SITE_URL}/feed.xml`;
|
||||||
|
const today = todayInTimeZone();
|
||||||
|
|
||||||
|
// Only publish verses whose calendar day has arrived. Pre-scheduled
|
||||||
|
// future verses (from the submission scheduling scan) and any stray
|
||||||
|
// future-dated rows must never leak into the feed early.
|
||||||
const verses = await db
|
const verses = await db
|
||||||
.select()
|
.select()
|
||||||
.from(dailyVerses)
|
.from(dailyVerses)
|
||||||
|
.where(lte(dailyVerses.date, today))
|
||||||
.orderBy(desc(dailyVerses.date))
|
.orderBy(desc(dailyVerses.date))
|
||||||
.limit(30);
|
.limit(FEED_ITEM_LIMIT);
|
||||||
|
|
||||||
// Generate ETag based on latest verse date
|
// Content-addressed ETag + Last-Modified let aggregators revalidate
|
||||||
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
|
// cheaply with If-None-Match / If-Modified-Since.
|
||||||
|
const etag = `"bibdle-feed-${feedFingerprint(verses)}"`;
|
||||||
|
const lastModifiedDate = latestModification(
|
||||||
|
verses,
|
||||||
|
new Date(`${today}T00:00:00Z`)
|
||||||
|
);
|
||||||
|
const lastModified = lastModifiedDate.toUTCString();
|
||||||
|
|
||||||
// Check if client has cached version
|
const notModified =
|
||||||
if (request.headers.get('If-None-Match') === etag) {
|
request.headers.get('If-None-Match') === etag ||
|
||||||
return new Response(null, { status: 304 });
|
(() => {
|
||||||
|
const since = request.headers.get('If-Modified-Since');
|
||||||
|
if (!since) return false;
|
||||||
|
const sinceMs = Date.parse(since);
|
||||||
|
return !Number.isNaN(sinceMs) && lastModifiedDate.getTime() <= sinceMs;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (notModified) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
ETag: etag,
|
||||||
|
'Last-Modified': lastModified,
|
||||||
|
'Cache-Control': 'public, max-age=3600, s-maxage=3600'
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get site URL from environment or use default
|
|
||||||
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
|
|
||||||
|
|
||||||
// Build RSS XML
|
|
||||||
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
|
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
|
||||||
|
|
||||||
const items = verses
|
const items = verses
|
||||||
.map(
|
.map((verse) => {
|
||||||
(verse) => `
|
const body = formatVerseText(verse.verseText);
|
||||||
|
const escaped = escapeXml(body);
|
||||||
|
return `
|
||||||
<item>
|
<item>
|
||||||
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
|
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
|
||||||
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
|
<description>${escaped}</description>
|
||||||
|
<content:encoded>${cdata(`<p>${escaped}</p>`)}</content:encoded>
|
||||||
<link>${SITE_URL}</link>
|
<link>${SITE_URL}</link>
|
||||||
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
||||||
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
||||||
</item>`
|
</item>`;
|
||||||
)
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<rss version="2.0">
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Bibdle</title>
|
<title>Bibdle</title>
|
||||||
<link>${SITE_URL}</link>
|
<link>${SITE_URL}</link>
|
||||||
<description>A daily Bible game</description>
|
<description>A daily Bible verse guessing game</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||||
<ttl>720</ttl>${items}
|
<pubDate>${lastBuildDate}</pubDate>
|
||||||
|
<generator>SvelteKit</generator>
|
||||||
|
<ttl>720</ttl>
|
||||||
|
<atom:link href="${escapeXml(FEED_URL)}" rel="self" type="application/rss+xml" />
|
||||||
|
<image>
|
||||||
|
<url>${SITE_URL}/favicon.ico</url>
|
||||||
|
<title>Bibdle</title>
|
||||||
|
<link>${SITE_URL}</link>
|
||||||
|
</image>${items}
|
||||||
</channel>
|
</channel>
|
||||||
</rss>`;
|
</rss>`;
|
||||||
|
|
||||||
@@ -135,11 +101,12 @@ export const GET: RequestHandler = async ({ request }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||||
ETag: etag
|
ETag: etag,
|
||||||
|
'Last-Modified': lastModified
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('RSS feed generation error:', error);
|
console.error('RSS feed generation error:', error);
|
||||||
return new Response('Internal Server Error', { status: 500 });
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
@@ -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 0–6)
|
||||||
|
// Week B: 7–13 days ago (indices 7–13)
|
||||||
|
// Week C: 14–20 days ago (indices 14–20)
|
||||||
|
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 53–59) vs prior 7 (idx 46–52)
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 & 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 8–14 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>
|
||||||
@@ -2,12 +2,29 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-triodion: "PT Serif", serif;
|
--font-triodion: "PT Serif", serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background: oklch(89.126% 0.06134 298.626);
|
background: oklch(89.126% 0.06134 298.626);
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html:not(.light), body:not(.light) {
|
||||||
|
background: oklch(18% 0.03 298.626);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark, html.dark body {
|
||||||
|
background: oklch(18% 0.03 298.626);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light, html.light body {
|
||||||
|
background: oklch(89.126% 0.06134 298.626);
|
||||||
}
|
}
|
||||||
|
|
||||||
.big-text {
|
.big-text {
|
||||||
@@ -18,6 +35,20 @@ html, body {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html:not(.light) .big-text {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .big-text {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light .big-text {
|
||||||
|
color: rgb(107 114 128);
|
||||||
|
}
|
||||||
|
|
||||||
/* Page load animations */
|
/* Page load animations */
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
← 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"
|
||||||
|
>
|
||||||
|
← 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 ≤ 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 • 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
← 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"
|
||||||
|
>
|
||||||
|
← 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"
|
||||||
|
>
|
||||||
|
← 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} />
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () => {
|
||||||
|
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://bibdle.com/</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://bibdle.com/about</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>`;
|
||||||
|
|
||||||
|
return new Response(sitemap, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -27,9 +27,9 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's current date from timezone query param
|
// Note: userToday is used only for the initial server-side streak estimate.
|
||||||
const timezone = url.searchParams.get('tz') || 'UTC';
|
// The client overrides this with a precise local-date calculation via /api/streak.
|
||||||
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
|
const userToday = new Date().toISOString().slice(0, 10); // UTC date as safe fallback
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all completions for this user
|
// Get all completions for this user
|
||||||
@@ -85,7 +85,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||||||
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate streaks
|
// Calculate streaks — dates are stored as the user's local date
|
||||||
const sortedDates = completions
|
const sortedDates = completions
|
||||||
.map((c: DailyCompletion) => c.date)
|
.map((c: DailyCompletion) => c.date)
|
||||||
.sort();
|
.sort();
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { enhance } from "$app/forms";
|
|
||||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
import Container from "$lib/components/Container.svelte";
|
import Container from "$lib/components/Container.svelte";
|
||||||
import { bibleBooks } from "$lib/types/bible";
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
import {
|
import {
|
||||||
getGradeColor,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
getStreakMessage,
|
|
||||||
getPerformanceMessage,
|
|
||||||
type UserStats,
|
type UserStats,
|
||||||
} from "$lib/utils/stats";
|
} from "$lib/utils/stats";
|
||||||
|
import { fetchStreak } from "$lib/utils/streak";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
stats: UserStats | null;
|
stats: UserStats | null;
|
||||||
@@ -27,6 +23,7 @@
|
|||||||
let anonymousId = $state("");
|
let anonymousId = $state("");
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let currentStreak = $state(0);
|
||||||
|
|
||||||
function getOrCreateAnonymousId(): string {
|
function getOrCreateAnonymousId(): string {
|
||||||
if (!browser) return "";
|
if (!browser) return "";
|
||||||
@@ -41,13 +38,15 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
|
if (data.user?.id) {
|
||||||
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
currentStreak = await fetchStreak(data.user.id, localDate);
|
||||||
|
} else {
|
||||||
|
currentStreak = data.stats?.currentStreak ?? 0;
|
||||||
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getGradePercentage(count: number, total: number): number {
|
|
||||||
return total > 0 ? Math.round((count / total) * 100) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBookName(bookId: string): string {
|
function getBookName(bookId: string): string {
|
||||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||||
}
|
}
|
||||||
@@ -160,7 +159,7 @@
|
|||||||
<div
|
<div
|
||||||
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||||
>
|
>
|
||||||
{stats.currentStreak}
|
{currentStreak}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||||
@@ -333,83 +332,7 @@
|
|||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grade Distribution -->
|
|
||||||
<Container class="p-5 md:p-6 mb-6">
|
|
||||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
|
|
||||||
Grade Distribution
|
|
||||||
</h2>
|
|
||||||
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
|
|
||||||
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
|
|
||||||
{@const percentage = getGradePercentage(
|
|
||||||
count,
|
|
||||||
stats.totalSolves,
|
|
||||||
)}
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-2">
|
|
||||||
<span
|
|
||||||
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
|
|
||||||
grade,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{grade}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-lg md:text-2xl font-bold text-gray-100"
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-400">
|
|
||||||
{percentage}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
<!-- Recent Performance -->
|
|
||||||
{#if stats.recentCompletions.length > 0}
|
|
||||||
<Container class="p-5 md:p-6">
|
|
||||||
<h2
|
|
||||||
class="text-lg md:text-xl font-bold text-gray-100 mb-4"
|
|
||||||
>
|
|
||||||
Recent Performance
|
|
||||||
</h2>
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
class="text-sm md:text-base font-medium text-gray-200"
|
|
||||||
>{formatDate(completion.date)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 md:gap-3"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="text-xs md:text-sm text-gray-300"
|
|
||||||
>{completion.guessCount} guess{completion.guessCount ===
|
|
||||||
1
|
|
||||||
? ""
|
|
||||||
: "es"}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
|
|
||||||
completion.grade,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{completion.grade}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# About Bibdle
|
||||||
|
|
||||||
|
Bibdle is a daily Bible guessing game. Every day, a random verse is posted to the website. Try to figure out which book of the Bible it comes from, in as few guesses as possible. That's it!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The game was built with the hope that it would be a small, delightful thing people can make part of their day. It's not a Bible study course. You don't need to know the Bible inside and out to enjoy it or to learn something from it.
|
||||||
|
|
||||||
|
If you're someone who grew up in church and can name all 66 books in order, great. If you're someone who can barely tell the Old Testament from the New, that's great too. The game meets you where you are.
|
||||||
|
|
||||||
|
It is completely free. If you'd like to support the developer (who works solely on small projects like this one) or express your thanks, you can become a Bibdle patron.
|
||||||
|
|
||||||
|
If you use Bibdle, I would love to hear from you! I can be reached via email or through Bluesky.
|
||||||
|
|
||||||
|
<!-- social -->
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
# How to Play
|
||||||
|
|
||||||
|
Each day, Bibdle gives you a verse from the Bible. Your job is to guess which book it comes from. (Genesis, John, Corinthians, etc.)
|
||||||
|
|
||||||
|
You have unlimited guesses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Basics
|
||||||
|
|
||||||
|
1. **Read the verse.** It appears at the top of the page.
|
||||||
|
2. **Make a guess.** Type or select a book of the Bible from the list.
|
||||||
|
3. **Read the feedback.** After each guess, you'll get clues telling you how close you were.
|
||||||
|
4. **Keep guessing** until you get it right.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feedback Hints
|
||||||
|
|
||||||
|
After each wrong guess, you'll see the following hints:
|
||||||
|
|
||||||
|
| Hint | What it means |
|
||||||
|
|---|---|
|
||||||
|
| **Testament** | If your guess was in the correct Testament (Old or New) |
|
||||||
|
| **Section** | If your guess was in the correct section of the Bible (e.g. Gospels, Epistles, Major Prophets) |
|
||||||
|
| **First Letter** | If your guess has the same first letter as the correct guess |
|
||||||
|
|
||||||
|
Use the hints to narrow down your search.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A Few Things to Know
|
||||||
|
|
||||||
|
- **Everyone plays the same verse each day.** The daily verse resets at midnight.
|
||||||
|
- **Your progress is saved automatically.** You can close the tab and come back later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Pay attention to writing style: the voice of Psalms is very different from Paul's letters.
|
||||||
|
- Historical narrative (battles, kings, genealogies) tends to be Old Testament.
|
||||||
|
- Short, poetic, or wisdom-focused verses could be Proverbs, Ecclesiastes, or Psalms.
|
||||||
|
- If a verse mentions Jesus by name, it's in the New Testament.
|
||||||
|
|
||||||
|
Good luck!
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
# allow crawling everything by default
|
# allow crawling everything by default
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
Disallow:
|
||||||
|
|
||||||
|
Sitemap: https://bibdle.com/sitemap.xml
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
getBookById,
|
||||||
|
getBookByNumber,
|
||||||
|
getBooksByTestament,
|
||||||
|
getBooksBySection,
|
||||||
|
isAdjacent,
|
||||||
|
bookNumberToId,
|
||||||
|
bookIdToNumber,
|
||||||
|
bibleBooks,
|
||||||
|
} from "$lib/server/bible";
|
||||||
|
|
||||||
|
describe("bibleBooks data integrity", () => {
|
||||||
|
test("contains exactly 66 books", () => {
|
||||||
|
expect(bibleBooks).toHaveLength(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("order numbers are 1 through 66 with no gaps or duplicates", () => {
|
||||||
|
const orders = bibleBooks.map((b) => b.order).sort((a, b) => a - b);
|
||||||
|
for (let i = 0; i < 66; i++) {
|
||||||
|
expect(orders[i]).toBe(i + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("book IDs are unique", () => {
|
||||||
|
const ids = bibleBooks.map((b) => b.id);
|
||||||
|
const unique = new Set(ids);
|
||||||
|
expect(unique.size).toBe(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every book has a non-empty name", () => {
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
expect(book.name.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Old Testament has 39 books", () => {
|
||||||
|
expect(bibleBooks.filter((b) => b.testament === "old")).toHaveLength(39);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New Testament has 27 books", () => {
|
||||||
|
expect(bibleBooks.filter((b) => b.testament === "new")).toHaveLength(27);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Genesis is first and Revelation is last", () => {
|
||||||
|
const sorted = [...bibleBooks].sort((a, b) => a.order - b.order);
|
||||||
|
expect(sorted[0].id).toBe("GEN");
|
||||||
|
expect(sorted[65].id).toBe("REV");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Matthew is the first New Testament book", () => {
|
||||||
|
const nt = bibleBooks
|
||||||
|
.filter((b) => b.testament === "new")
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
expect(nt[0].id).toBe("MAT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("section counts", () => {
|
||||||
|
test("Law: 5 books", () => {
|
||||||
|
expect(getBooksBySection("Law")).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("History: 13 books (12 OT + Acts)", () => {
|
||||||
|
expect(getBooksBySection("History")).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wisdom: 5 books", () => {
|
||||||
|
expect(getBooksBySection("Wisdom")).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Major Prophets: 5 books", () => {
|
||||||
|
expect(getBooksBySection("Major Prophets")).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Minor Prophets: 12 books", () => {
|
||||||
|
expect(getBooksBySection("Minor Prophets")).toHaveLength(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gospels: 4 books", () => {
|
||||||
|
expect(getBooksBySection("Gospels")).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Pauline Epistles: 13 books", () => {
|
||||||
|
expect(getBooksBySection("Pauline Epistles")).toHaveLength(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("General Epistles: 8 books", () => {
|
||||||
|
expect(getBooksBySection("General Epistles")).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Apocalyptic: 1 book (Revelation)", () => {
|
||||||
|
const books = getBooksBySection("Apocalyptic");
|
||||||
|
expect(books).toHaveLength(1);
|
||||||
|
expect(books[0].id).toBe("REV");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all sections sum to 66", () => {
|
||||||
|
const total =
|
||||||
|
getBooksBySection("Law").length +
|
||||||
|
getBooksBySection("History").length +
|
||||||
|
getBooksBySection("Wisdom").length +
|
||||||
|
getBooksBySection("Major Prophets").length +
|
||||||
|
getBooksBySection("Minor Prophets").length +
|
||||||
|
getBooksBySection("Gospels").length +
|
||||||
|
getBooksBySection("Pauline Epistles").length +
|
||||||
|
getBooksBySection("General Epistles").length +
|
||||||
|
getBooksBySection("Apocalyptic").length;
|
||||||
|
expect(total).toBe(66);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBookById", () => {
|
||||||
|
test("returns Genesis for GEN", () => {
|
||||||
|
const book = getBookById("GEN");
|
||||||
|
expect(book).toBeDefined();
|
||||||
|
expect(book!.name).toBe("Genesis");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns Revelation for REV", () => {
|
||||||
|
const book = getBookById("REV");
|
||||||
|
expect(book!.name).toBe("Revelation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for unknown ID", () => {
|
||||||
|
expect(getBookById("UNKNOWN")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBookByNumber", () => {
|
||||||
|
test("1 → Genesis", () => {
|
||||||
|
expect(getBookByNumber(1)!.id).toBe("GEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("66 → Revelation", () => {
|
||||||
|
expect(getBookByNumber(66)!.id).toBe("REV");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("40 → Matthew (first NT book)", () => {
|
||||||
|
expect(getBookByNumber(40)!.id).toBe("MAT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("0 → undefined", () => {
|
||||||
|
expect(getBookByNumber(0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("67 → undefined", () => {
|
||||||
|
expect(getBookByNumber(67)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBooksByTestament", () => {
|
||||||
|
test("old returns 39 books", () => {
|
||||||
|
expect(getBooksByTestament("old")).toHaveLength(39);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new returns 27 books", () => {
|
||||||
|
expect(getBooksByTestament("new")).toHaveLength(27);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all OT books have testament = old", () => {
|
||||||
|
for (const book of getBooksByTestament("old")) {
|
||||||
|
expect(book.testament).toBe("old");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all NT books have testament = new", () => {
|
||||||
|
for (const book of getBooksByTestament("new")) {
|
||||||
|
expect(book.testament).toBe("new");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAdjacent", () => {
|
||||||
|
test("Genesis and Exodus are adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "EXO")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacency is symmetric", () => {
|
||||||
|
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Malachi and Matthew are adjacent across testament boundary", () => {
|
||||||
|
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Jude and Revelation are adjacent", () => {
|
||||||
|
expect(isAdjacent("JUD", "REV")).toBe(true); // 65, 66
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same book is not adjacent to itself", () => {
|
||||||
|
expect(isAdjacent("GEN", "GEN")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("books two apart are not adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for invalid IDs", () => {
|
||||||
|
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bookNumberToId / bookIdToNumber lookup tables", () => {
|
||||||
|
test("bookNumberToId[1] is GEN", () => {
|
||||||
|
expect(bookNumberToId[1]).toBe("GEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bookNumberToId[66] is REV", () => {
|
||||||
|
expect(bookNumberToId[66]).toBe("REV");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bookIdToNumber['GEN'] is 1", () => {
|
||||||
|
expect(bookIdToNumber["GEN"]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bookIdToNumber['REV'] is 66", () => {
|
||||||
|
expect(bookIdToNumber["REV"]).toBe(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trip: number → ID → number", () => {
|
||||||
|
for (let i = 1; i <= 66; i++) {
|
||||||
|
const id = bookNumberToId[i];
|
||||||
|
expect(bookIdToNumber[id]).toBe(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trip: ID → number → ID", () => {
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
const num = bookIdToNumber[book.id];
|
||||||
|
expect(bookNumberToId[num]).toBe(book.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
evaluateGuess,
|
||||||
|
getBookById,
|
||||||
|
getFirstLetter,
|
||||||
|
getGrade,
|
||||||
|
getNextGradeMessage,
|
||||||
|
isAdjacent,
|
||||||
|
toOrdinal,
|
||||||
|
} from "$lib/utils/game";
|
||||||
|
|
||||||
|
describe("getBookById", () => {
|
||||||
|
test("returns correct book for a valid ID", () => {
|
||||||
|
const book = getBookById("GEN");
|
||||||
|
expect(book).toBeDefined();
|
||||||
|
expect(book!.name).toBe("Genesis");
|
||||||
|
expect(book!.order).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the last book by ID", () => {
|
||||||
|
const book = getBookById("REV");
|
||||||
|
expect(book).toBeDefined();
|
||||||
|
expect(book!.name).toBe("Revelation");
|
||||||
|
expect(book!.order).toBe(66);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for an invalid ID", () => {
|
||||||
|
expect(getBookById("INVALID")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for an empty string", () => {
|
||||||
|
expect(getBookById("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is case-sensitive", () => {
|
||||||
|
expect(getBookById("gen")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAdjacent", () => {
|
||||||
|
test("consecutive books are adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "EXO")).toBe(true); // 1, 2
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacency is symmetric", () => {
|
||||||
|
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("books two apart are not adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the same book is not adjacent to itself", () => {
|
||||||
|
expect(isAdjacent("GEN", "GEN")).toBe(false); // diff = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works across testament boundary (Malachi / Matthew)", () => {
|
||||||
|
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||||
|
});
|
||||||
|
|
||||||
|
test("far-apart books are not adjacent", () => {
|
||||||
|
expect(isAdjacent("GEN", "REV")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for unknown IDs", () => {
|
||||||
|
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||||
|
expect(isAdjacent("GEN", "FAKE")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFirstLetter", () => {
|
||||||
|
test("returns first letter of a normal book name", () => {
|
||||||
|
expect(getFirstLetter("Genesis")).toBe("G");
|
||||||
|
expect(getFirstLetter("Revelation")).toBe("R");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips leading digits and returns first letter", () => {
|
||||||
|
expect(getFirstLetter("1 Samuel")).toBe("S");
|
||||||
|
expect(getFirstLetter("2 Kings")).toBe("K");
|
||||||
|
expect(getFirstLetter("1 Corinthians")).toBe("C");
|
||||||
|
expect(getFirstLetter("3 John")).toBe("J");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns first letter of multi-word names", () => {
|
||||||
|
expect(getFirstLetter("Song of Solomon")).toBe("S");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("evaluateGuess", () => {
|
||||||
|
test("returns null for an invalid guess ID", () => {
|
||||||
|
expect(evaluateGuess("INVALID", "GEN")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for an invalid correct ID", () => {
|
||||||
|
expect(evaluateGuess("GEN", "INVALID")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exact book match: testamentMatch and sectionMatch are true, adjacent is false", () => {
|
||||||
|
const result = evaluateGuess("GEN", "GEN");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.book.id).toBe("GEN");
|
||||||
|
expect(result!.testamentMatch).toBe(true);
|
||||||
|
expect(result!.sectionMatch).toBe(true);
|
||||||
|
expect(result!.adjacent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same section implies same testament", () => {
|
||||||
|
// Genesis and Exodus are both OT Law
|
||||||
|
const result = evaluateGuess("GEN", "EXO");
|
||||||
|
expect(result!.testamentMatch).toBe(true);
|
||||||
|
expect(result!.sectionMatch).toBe(true);
|
||||||
|
expect(result!.adjacent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same testament, different section", () => {
|
||||||
|
// Genesis (Law) vs Joshua (History) — both OT
|
||||||
|
const result = evaluateGuess("GEN", "JOS");
|
||||||
|
expect(result!.testamentMatch).toBe(true);
|
||||||
|
expect(result!.sectionMatch).toBe(false);
|
||||||
|
expect(result!.adjacent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different testament, no match", () => {
|
||||||
|
// Genesis (OT) vs Matthew (NT)
|
||||||
|
const result = evaluateGuess("GEN", "MAT");
|
||||||
|
expect(result!.testamentMatch).toBe(false);
|
||||||
|
expect(result!.sectionMatch).toBe(false);
|
||||||
|
expect(result!.adjacent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacent books across testament boundary", () => {
|
||||||
|
// Malachi (OT, 39) and Matthew (NT, 40)
|
||||||
|
const result = evaluateGuess("MAL", "MAT");
|
||||||
|
expect(result!.adjacent).toBe(true);
|
||||||
|
expect(result!.testamentMatch).toBe(false);
|
||||||
|
expect(result!.sectionMatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacent books within same testament and section", () => {
|
||||||
|
// Hosea (28) and Joel (29), both Minor Prophets
|
||||||
|
const result = evaluateGuess("HOS", "JOL");
|
||||||
|
expect(result!.adjacent).toBe(true);
|
||||||
|
expect(result!.testamentMatch).toBe(true);
|
||||||
|
expect(result!.sectionMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firstLetterMatch: same first letter", () => {
|
||||||
|
// Genesis and Galatians both start with G
|
||||||
|
const result = evaluateGuess("GEN", "GAL");
|
||||||
|
expect(result!.firstLetterMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firstLetterMatch: different first letter", () => {
|
||||||
|
// Genesis (G) vs Matthew (M)
|
||||||
|
const result = evaluateGuess("GEN", "MAT");
|
||||||
|
expect(result!.firstLetterMatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firstLetterMatch is case-insensitive", () => {
|
||||||
|
// Both start with J but from different contexts — Jeremiah vs Joel
|
||||||
|
const result = evaluateGuess("JER", "JOL");
|
||||||
|
expect(result!.firstLetterMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("special case: two Epistle '1' books always firstLetterMatch", () => {
|
||||||
|
// 1 Corinthians (Pauline) vs 1 John (General) — both Epistles starting with "1"
|
||||||
|
const result = evaluateGuess("1CO", "1JN");
|
||||||
|
expect(result!.firstLetterMatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("special case: Epistle '1' book vs non-Epistle '1' book — no special treatment", () => {
|
||||||
|
// 1 Corinthians (Pauline Epistles) vs 1 Samuel (History)
|
||||||
|
// Correct is NOT Epistles, so special case doesn't apply
|
||||||
|
const result = evaluateGuess("1CO", "1SA");
|
||||||
|
// getFirstLetter("1 Corinthians") = "C", getFirstLetter("1 Samuel") = "S" → false
|
||||||
|
expect(result!.firstLetterMatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("special case only triggers when BOTH are Epistle '1' books", () => {
|
||||||
|
// 2 Corinthians (Pauline, starts with "2") vs 1 John (General, starts with "1")
|
||||||
|
// guessIsEpistlesWithNumber requires name[0] === "1", so 2CO fails
|
||||||
|
const result = evaluateGuess("2CO", "1JN");
|
||||||
|
// getFirstLetter("2 Corinthians") = "C", getFirstLetter("1 John") = "J" → false
|
||||||
|
expect(result!.firstLetterMatch).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getGrade", () => {
|
||||||
|
test("1 guess → S+", () => expect(getGrade(1)).toBe("S+"));
|
||||||
|
test("2 guesses → A+", () => expect(getGrade(2)).toBe("A+"));
|
||||||
|
test("3 guesses → A", () => expect(getGrade(3)).toBe("A"));
|
||||||
|
test("4 guesses → B+", () => expect(getGrade(4)).toBe("B+"));
|
||||||
|
test("6 guesses → B+", () => expect(getGrade(6)).toBe("B+"));
|
||||||
|
test("7 guesses → B", () => expect(getGrade(7)).toBe("B"));
|
||||||
|
test("10 guesses → B", () => expect(getGrade(10)).toBe("B"));
|
||||||
|
test("11 guesses → C+", () => expect(getGrade(11)).toBe("C+"));
|
||||||
|
test("15 guesses → C+", () => expect(getGrade(15)).toBe("C+"));
|
||||||
|
test("16 guesses → C", () => expect(getGrade(16)).toBe("C"));
|
||||||
|
test("100 guesses → C", () => expect(getGrade(100)).toBe("C"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getNextGradeMessage", () => {
|
||||||
|
test("returns empty string at top grade", () => {
|
||||||
|
expect(getNextGradeMessage(1)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade A+ shows 1 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(2)).toBe("Next grade: 1 guess or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade A shows 2 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(3)).toBe("Next grade: 2 guesses or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade B+ shows 3 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(4)).toBe("Next grade: 3 guesses or less");
|
||||||
|
expect(getNextGradeMessage(6)).toBe("Next grade: 3 guesses or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade B shows 6 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(7)).toBe("Next grade: 6 guesses or less");
|
||||||
|
expect(getNextGradeMessage(10)).toBe("Next grade: 6 guesses or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade C+ shows 10 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(11)).toBe("Next grade: 10 guesses or less");
|
||||||
|
expect(getNextGradeMessage(15)).toBe("Next grade: 10 guesses or less");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("grade C shows 15 guess threshold", () => {
|
||||||
|
expect(getNextGradeMessage(16)).toBe("Next grade: 15 guesses or less");
|
||||||
|
expect(getNextGradeMessage(50)).toBe("Next grade: 15 guesses or less");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toOrdinal", () => {
|
||||||
|
test("1st, 2nd, 3rd", () => {
|
||||||
|
expect(toOrdinal(1)).toBe("1st");
|
||||||
|
expect(toOrdinal(2)).toBe("2nd");
|
||||||
|
expect(toOrdinal(3)).toBe("3rd");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4-10 use th", () => {
|
||||||
|
expect(toOrdinal(4)).toBe("4th");
|
||||||
|
expect(toOrdinal(10)).toBe("10th");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("11, 12, 13 use th (not st/nd/rd)", () => {
|
||||||
|
expect(toOrdinal(11)).toBe("11th");
|
||||||
|
expect(toOrdinal(12)).toBe("12th");
|
||||||
|
expect(toOrdinal(13)).toBe("13th");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("21, 22, 23 use st/nd/rd", () => {
|
||||||
|
expect(toOrdinal(21)).toBe("21st");
|
||||||
|
expect(toOrdinal(22)).toBe("22nd");
|
||||||
|
expect(toOrdinal(23)).toBe("23rd");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("101, 102, 103 use st/nd/rd", () => {
|
||||||
|
expect(toOrdinal(101)).toBe("101st");
|
||||||
|
expect(toOrdinal(102)).toBe("102nd");
|
||||||
|
expect(toOrdinal(103)).toBe("103rd");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("111, 112, 113 use th", () => {
|
||||||
|
expect(toOrdinal(111)).toBe("111th");
|
||||||
|
expect(toOrdinal(112)).toBe("112th");
|
||||||
|
expect(toOrdinal(113)).toBe("113th");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import {
|
||||||
|
cdata,
|
||||||
|
escapeXml,
|
||||||
|
feedFingerprint,
|
||||||
|
formatRFC822,
|
||||||
|
formatReadableDate,
|
||||||
|
formatVerseText,
|
||||||
|
latestModification,
|
||||||
|
todayInTimeZone
|
||||||
|
} from '$lib/server/rss';
|
||||||
|
|
||||||
|
describe('RSS date formatting', () => {
|
||||||
|
test('formatReadableDate uses the calendar day, independent of server TZ', () => {
|
||||||
|
expect(formatReadableDate('2026-09-18')).toBe('September 18, 2026');
|
||||||
|
expect(formatReadableDate('2026-01-01')).toBe('January 1, 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatRFC822 emits a valid GMT timestamp', () => {
|
||||||
|
expect(formatRFC822('2026-01-15')).toBe('Thu, 15 Jan 2026 05:00:00 GMT');
|
||||||
|
expect(formatRFC822('2026-09-18')).toBe('Fri, 18 Sep 2026 04:00:00 GMT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatRFC822 handles daylight saving (EST -05:00 vs EDT -04:00)', () => {
|
||||||
|
// Winter: midnight New York is 05:00 UTC.
|
||||||
|
expect(formatRFC822('2026-01-15')).toContain('05:00:00 GMT');
|
||||||
|
// Summer: midnight New York is 04:00 UTC.
|
||||||
|
expect(formatRFC822('2026-07-15')).toContain('04:00:00 GMT');
|
||||||
|
// Never a bare, ambiguous abbreviation like EST/EDT.
|
||||||
|
expect(formatRFC822('2026-07-15')).not.toMatch(/\bE[SD]T\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('todayInTimeZone respects the reference timezone', () => {
|
||||||
|
// 02:00 UTC on the 23rd is still the evening of the 22nd in New York.
|
||||||
|
expect(
|
||||||
|
todayInTimeZone('America/New_York', new Date('2026-09-23T02:00:00Z'))
|
||||||
|
).toBe('2026-09-22');
|
||||||
|
// 04:00 UTC on the 23rd is midnight in New York.
|
||||||
|
expect(
|
||||||
|
todayInTimeZone('America/New_York', new Date('2026-09-23T04:00:00Z'))
|
||||||
|
).toBe('2026-09-23');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RSS content fingerprint', () => {
|
||||||
|
const row = { date: '2026-09-18', reference: 'Leviticus 19:9-11', verseText: 'a' };
|
||||||
|
|
||||||
|
test('is stable for identical content', () => {
|
||||||
|
expect(feedFingerprint([row])).toBe(feedFingerprint([{ ...row }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changes when verse text changes', () => {
|
||||||
|
expect(feedFingerprint([row])).not.toBe(feedFingerprint([{ ...row, verseText: 'b' }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changes when the set of days changes', () => {
|
||||||
|
expect(feedFingerprint([row])).not.toBe(
|
||||||
|
feedFingerprint([row, { ...row, date: '2026-09-17' }])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('latestModification', () => {
|
||||||
|
const fallback = new Date('2026-09-18T00:00:00Z');
|
||||||
|
|
||||||
|
test('returns the newest createdAt', () => {
|
||||||
|
const older = new Date('2026-09-16T10:00:00Z');
|
||||||
|
const newer = new Date('2026-09-17T10:00:00Z');
|
||||||
|
expect(
|
||||||
|
latestModification([{ createdAt: older }, { createdAt: newer }], fallback)
|
||||||
|
).toEqual(newer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back when rows have no timestamp', () => {
|
||||||
|
expect(latestModification([{ createdAt: null }], fallback)).toEqual(fallback);
|
||||||
|
expect(latestModification([], fallback)).toEqual(fallback);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('XML serialization helpers', () => {
|
||||||
|
test('escapeXml escapes all predefined entities', () => {
|
||||||
|
expect(escapeXml(`<a href="x">&'</a>`)).toBe(
|
||||||
|
'<a href="x">&'</a>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cdata splits embedded CDATA terminators', () => {
|
||||||
|
expect(cdata('a]]>b')).toBe('<![CDATA[a]]]]><![CDATA[>b]]>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatVerseText capitalizes and replaces trailing punctuation', () => {
|
||||||
|
expect(formatVerseText('for God so loved the world,')).toBe('For God so loved the world...');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { generateShareText, getVerseSnippet } from "$lib/utils/share";
|
||||||
|
import { getBookById } from "$lib/utils/game";
|
||||||
|
import type { Guess } from "$lib/utils/game";
|
||||||
|
|
||||||
|
// Helpers to build Guess objects without calling evaluateGuess
|
||||||
|
function makeGuess(bookId: string, overrides: Partial<Omit<Guess, "book">> = {}): Guess {
|
||||||
|
const book = getBookById(bookId)!;
|
||||||
|
return {
|
||||||
|
book,
|
||||||
|
testamentMatch: false,
|
||||||
|
sectionMatch: false,
|
||||||
|
adjacent: false,
|
||||||
|
firstLetterMatch: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORRECT_BOOK_ID = "GEN";
|
||||||
|
|
||||||
|
const exactGuess = makeGuess("GEN", {
|
||||||
|
testamentMatch: true,
|
||||||
|
sectionMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjacentGuess = makeGuess("EXO", {
|
||||||
|
testamentMatch: true,
|
||||||
|
sectionMatch: true,
|
||||||
|
adjacent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionGuess = makeGuess("LEV", {
|
||||||
|
testamentMatch: true,
|
||||||
|
sectionMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testamentGuess = makeGuess("JOS", {
|
||||||
|
testamentMatch: true,
|
||||||
|
sectionMatch: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const noMatchGuess = makeGuess("MAT", {
|
||||||
|
testamentMatch: false,
|
||||||
|
sectionMatch: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — emoji mapping", () => {
|
||||||
|
test("exact match → ✅", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("✅");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacent book → ‼️", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [adjacentGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("‼️");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("section match → 🟩", () => {
|
||||||
|
// LEV matches section (Law) but is not adjacent to GEN (order 1 vs 3)
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [sectionGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("🟩");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("testament match only → 🟧", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [testamentGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("🟧");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no match → 🟥", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [noMatchGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "In the beginning...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("🟥");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — guess count wording", () => {
|
||||||
|
test("1 guess uses singular 'guess'", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("1 guess,");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple guesses uses plural 'guesses'", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [noMatchGuess, testamentGuess, exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("3 guesses,");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — streak display", () => {
|
||||||
|
test("streak > 1 is shown with fire emoji", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
streak: 5,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("5 days 🔥");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streak of 1 is not shown", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
streak: 1,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).not.toContain("🔥");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("undefined streak is not shown", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).not.toContain("🔥");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — chapter star", () => {
|
||||||
|
test("1 guess + chapterCorrect → ⭐ appended to emoji line", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: true,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("✅ ⭐");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple guesses + chapterCorrect → no star (only awarded for hole-in-one)", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [noMatchGuess, exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: true,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).not.toContain("⭐");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1 guess + chapterCorrect false → no star", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).not.toContain("⭐");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — login book emoji", () => {
|
||||||
|
test("logged in uses 📜", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: true,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("📜");
|
||||||
|
expect(text).not.toContain("📖");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not logged in uses 📖", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("📖");
|
||||||
|
expect(text).not.toContain("📜");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — date formatting", () => {
|
||||||
|
test("date is formatted as 'Mon DD, YYYY'", () => {
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
expect(text).toContain("Jan 15, 2025");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateShareText — guess order", () => {
|
||||||
|
test("guesses are reversed in the emoji line (first guess last)", () => {
|
||||||
|
// noMatchGuess first, then exactGuess — reversed output: ✅🟥
|
||||||
|
const text = generateShareText({
|
||||||
|
guesses: [noMatchGuess, exactGuess],
|
||||||
|
correctBookId: CORRECT_BOOK_ID,
|
||||||
|
dailyVerseDate: "2025-01-15",
|
||||||
|
chapterCorrect: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
origin: "https://bibdle.com",
|
||||||
|
verseText: "...",
|
||||||
|
});
|
||||||
|
const lines = text.split("\n");
|
||||||
|
expect(lines[2]).toBe("✅🟥");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getVerseSnippet", () => {
|
||||||
|
test("wraps output in curly double quotes", () => {
|
||||||
|
const result = getVerseSnippet("Hello world");
|
||||||
|
expect(result.startsWith("\u201C")).toBe(true);
|
||||||
|
expect(result.endsWith("\u201D")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("short verse (fewer than 10 words) returns full text", () => {
|
||||||
|
const result = getVerseSnippet("For God so loved");
|
||||||
|
// No punctuation search happens, returns all words
|
||||||
|
expect(result).toContain("For God so loved");
|
||||||
|
expect(result).toContain("...");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verse with no punctuation in range returns first 25 words", () => {
|
||||||
|
const words = Array.from({ length: 30 }, (_, i) => `word${i + 1}`);
|
||||||
|
const verse = words.join(" ");
|
||||||
|
const result = getVerseSnippet(verse);
|
||||||
|
// Should contain up to 25 words
|
||||||
|
expect(result).toContain("word25");
|
||||||
|
expect(result).not.toContain("word26");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates at punctuation between words 10 and 25", () => {
|
||||||
|
// 12 words before comma, rest after
|
||||||
|
const verse =
|
||||||
|
"one two three four five six seven eight nine ten eleven twelve, thirteen fourteen fifteen twenty";
|
||||||
|
const result = getVerseSnippet(verse);
|
||||||
|
// The comma is after word 12, which is between word 10 and 25
|
||||||
|
expect(result).toContain("twelve");
|
||||||
|
expect(result).not.toContain("thirteen");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("punctuation before word 10 does not trigger truncation", () => {
|
||||||
|
// Comma is after word 5 — before the search window starts at word 10
|
||||||
|
const verse =
|
||||||
|
"one two three four five, six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen";
|
||||||
|
const result = getVerseSnippet(verse);
|
||||||
|
// The comma at word 5 is before start of search range, so we continue
|
||||||
|
// The snippet should contain word 10 at minimum
|
||||||
|
expect(result).toContain("ten");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not include trailing whitespace before ellipsis", () => {
|
||||||
|
const verse =
|
||||||
|
"one two three four five six seven eight nine ten eleven twelve, rest of verse here";
|
||||||
|
const result = getVerseSnippet(verse);
|
||||||
|
// trimEnd is applied before adding ..., so no space before ...
|
||||||
|
expect(result).not.toMatch(/\s\.\.\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
getGradeColor,
|
||||||
|
getPerformanceMessage,
|
||||||
|
getStreakMessage,
|
||||||
|
} from "$lib/utils/stats";
|
||||||
|
|
||||||
|
describe("getGradeColor", () => {
|
||||||
|
test("S++ → purple", () => {
|
||||||
|
expect(getGradeColor("S++")).toBe("text-purple-600 bg-purple-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("S+ → yellow", () => {
|
||||||
|
expect(getGradeColor("S+")).toBe("text-yellow-600 bg-yellow-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("A+ → green", () => {
|
||||||
|
expect(getGradeColor("A+")).toBe("text-green-600 bg-green-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("A → light green", () => {
|
||||||
|
expect(getGradeColor("A")).toBe("text-green-500 bg-green-50");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B+ → blue", () => {
|
||||||
|
expect(getGradeColor("B+")).toBe("text-blue-600 bg-blue-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("B → light blue", () => {
|
||||||
|
expect(getGradeColor("B")).toBe("text-blue-500 bg-blue-50");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C+ → orange", () => {
|
||||||
|
expect(getGradeColor("C+")).toBe("text-orange-600 bg-orange-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("C → red", () => {
|
||||||
|
expect(getGradeColor("C")).toBe("text-red-600 bg-red-100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown grade → gray fallback", () => {
|
||||||
|
expect(getGradeColor("X")).toBe("text-gray-600 bg-gray-100");
|
||||||
|
expect(getGradeColor("")).toBe("text-gray-600 bg-gray-100");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDate", () => {
|
||||||
|
test("formats a mid-year date", () => {
|
||||||
|
expect(formatDate("2024-07-04")).toBe("Jul 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats a January date", () => {
|
||||||
|
expect(formatDate("2024-01-15")).toBe("Jan 15");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats the last day of the year", () => {
|
||||||
|
expect(formatDate("2023-12-31")).toBe("Dec 31");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats a single-digit day without leading zero", () => {
|
||||||
|
expect(formatDate("2025-03-01")).toBe("Mar 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("year in input does not appear in output", () => {
|
||||||
|
expect(formatDate("2024-06-20")).not.toContain("2024");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStreakMessage", () => {
|
||||||
|
test("0 → prompt to start", () => {
|
||||||
|
expect(getStreakMessage(0)).toBe("Start your streak today!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1 → encouragement", () => {
|
||||||
|
expect(getStreakMessage(1)).toBe("Keep it going!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2 → X days strong", () => {
|
||||||
|
expect(getStreakMessage(2)).toBe("2 days strong!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("6 → X days strong (upper bound of that range)", () => {
|
||||||
|
expect(getStreakMessage(6)).toBe("6 days strong!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("7 → week streak message", () => {
|
||||||
|
expect(getStreakMessage(7)).toBe("7 day streak - amazing!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("29 → upper bound of week-streak range", () => {
|
||||||
|
expect(getStreakMessage(29)).toBe("29 day streak - amazing!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("30 → unstoppable message", () => {
|
||||||
|
expect(getStreakMessage(30)).toBe("30 days - you're unstoppable!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("100 → unstoppable message", () => {
|
||||||
|
expect(getStreakMessage(100)).toBe("100 days - you're unstoppable!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPerformanceMessage", () => {
|
||||||
|
test("≤ 2 guesses → exceptional", () => {
|
||||||
|
expect(getPerformanceMessage(1)).toBe("Exceptional performance!");
|
||||||
|
expect(getPerformanceMessage(2)).toBe("Exceptional performance!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("≤ 4 guesses → great", () => {
|
||||||
|
expect(getPerformanceMessage(2.1)).toBe("Great performance!");
|
||||||
|
expect(getPerformanceMessage(4)).toBe("Great performance!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("≤ 6 guesses → good", () => {
|
||||||
|
expect(getPerformanceMessage(4.1)).toBe("Good performance!");
|
||||||
|
expect(getPerformanceMessage(6)).toBe("Good performance!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("≤ 8 guesses → room for improvement", () => {
|
||||||
|
expect(getPerformanceMessage(6.1)).toBe("Room for improvement!");
|
||||||
|
expect(getPerformanceMessage(8)).toBe("Room for improvement!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("> 8 guesses → keep practicing", () => {
|
||||||
|
expect(getPerformanceMessage(8.1)).toBe("Keep practicing!");
|
||||||
|
expect(getPerformanceMessage(20)).toBe("Keep practicing!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -496,3 +496,149 @@ describe('Timezone-aware daily verse system', () => {
|
|||||||
// See /api/submit-completion for the unique constraint enforcement
|
// See /api/submit-completion for the unique constraint enforcement
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streak walk-back logic — mirrors /api/streak/+server.ts
|
||||||
|
// UTC is NEVER used for date comparison; all walk-back is pure string arithmetic.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function prevDay(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() - 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcStreak(completedDates: Set<string>, localDate: string): number {
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = localDate;
|
||||||
|
while (completedDates.has(cursor)) {
|
||||||
|
streak++;
|
||||||
|
cursor = prevDay(cursor);
|
||||||
|
}
|
||||||
|
return streak < 2 ? 0 : streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Streak walk-back — local time always used, UTC never', () => {
|
||||||
|
test('prevDay uses UTC arithmetic, never server local time', () => {
|
||||||
|
// Regardless of server timezone, prevDay("2024-03-10") must always be "2024-03-09"
|
||||||
|
// (including across DST boundaries where local midnight ≠ UTC midnight)
|
||||||
|
expect(prevDay('2024-03-10')).toBe('2024-03-09'); // DST spring-forward in US
|
||||||
|
expect(prevDay('2024-11-03')).toBe('2024-11-02'); // DST fall-back in US
|
||||||
|
expect(prevDay('2024-01-01')).toBe('2023-12-31'); // year boundary
|
||||||
|
expect(prevDay('2024-03-01')).toBe('2024-02-29'); // leap year
|
||||||
|
expect(prevDay('2023-03-01')).toBe('2023-02-28'); // non-leap year
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UTC+9 user: plays at 01:00 local (still previous UTC date) — streak uses local date', () => {
|
||||||
|
// Scenario: It is 2024-01-16 01:00 in Tokyo (UTC+9) = 2024-01-15 16:00 UTC.
|
||||||
|
// The verse served is for 2024-01-15 (UTC), but the user's LOCAL date is 2024-01-16.
|
||||||
|
// Completions are stored as the user's local date (as returned by dailyVerse.date
|
||||||
|
// which is set from the client's new Date().toLocaleDateString("en-CA")).
|
||||||
|
// The streak walk-back must use local dates, not UTC dates.
|
||||||
|
|
||||||
|
// Four consecutive local dates for a Tokyo user
|
||||||
|
const completedLocalDates = new Set(['2024-01-13', '2024-01-14', '2024-01-15', '2024-01-16']);
|
||||||
|
|
||||||
|
// If we (incorrectly) walked back from the UTC date "2024-01-15" instead of
|
||||||
|
// the local date "2024-01-16", we would miss the most recent completion.
|
||||||
|
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-15');
|
||||||
|
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-16');
|
||||||
|
|
||||||
|
// UTC walk-back misses the local "2024-01-16" entry → only finds 3 consecutive days
|
||||||
|
// (but actually it finds 2024-01-15, 2024-01-14, 2024-01-13 = 3, returned as 3)
|
||||||
|
// The point is it does NOT include 2024-01-16 which is "today" for the user.
|
||||||
|
expect(wrongStreakIfUTC).toBe(3); // stale — missing today's local entry
|
||||||
|
|
||||||
|
// Local walk-back correctly finds all four entries
|
||||||
|
expect(correctStreakWithLocalDate).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UTC-8 user: plays at 23:00 local (next UTC date) — streak uses local date', () => {
|
||||||
|
// Scenario: It is 2024-01-15 23:00 in Los Angeles (UTC-8) = 2024-01-16 07:00 UTC.
|
||||||
|
// The verse served is for 2024-01-16 (UTC), but the user's LOCAL date is still 2024-01-15.
|
||||||
|
// Completion is stored as local date "2024-01-15".
|
||||||
|
// Walk-back from "2024-01-15" (local) must find it; walk-back from "2024-01-16" (UTC)
|
||||||
|
// would NOT find it (the entry is stored as "2024-01-15").
|
||||||
|
|
||||||
|
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14', '2024-01-15']);
|
||||||
|
|
||||||
|
// If we (incorrectly) walked back from the UTC date "2024-01-16" instead of
|
||||||
|
// the local date "2024-01-15", "2024-01-16" is not in the set → streak = 0.
|
||||||
|
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-16');
|
||||||
|
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-15');
|
||||||
|
|
||||||
|
expect(wrongStreakIfUTC).toBe(0); // broken — UTC date not in DB
|
||||||
|
expect(correctStreakWithLocalDate).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UTC+13 user (Samoa): local date is two days ahead of UTC-11', () => {
|
||||||
|
// Extreme case: UTC+13 user on 2024-01-16 local = 2024-01-15 UTC.
|
||||||
|
const completedLocalDates = new Set(['2024-01-14', '2024-01-15', '2024-01-16']);
|
||||||
|
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-14')).toBe(0); // only 1 day (< 2)
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(3); // correct local streak
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0); // UTC date misses Jan 16
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streak is 0 when today is missing even if yesterday exists', () => {
|
||||||
|
// User missed today — streak must reset regardless of timezone
|
||||||
|
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14']);
|
||||||
|
// "Today" is 2024-01-16 — they missed the 15th and 16th
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streak suppressed below 2 — single day returns 0', () => {
|
||||||
|
const completedLocalDates = new Set(['2024-01-15']);
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DB entries with local date store correctly alongside UTC completion timestamp', async () => {
|
||||||
|
// Verify DB round-trip: storing completions with local dates (not UTC)
|
||||||
|
// ensures streak calculation with local dates always works.
|
||||||
|
const userId = 'tz-test-user-' + crypto.randomUUID();
|
||||||
|
|
||||||
|
// UTC+9 scenario: played at 01:00 local on Jan 16 (= UTC Jan 15)
|
||||||
|
// Local date is Jan 16, stored as "2024-01-16" in the DB.
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-14', // local date
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-13T15:00:00Z'), // UTC
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-15', // local date
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2024-01-14T15:00:00Z'), // UTC
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-16', // local date — UTC equivalent is 2024-01-15
|
||||||
|
guessCount: 1,
|
||||||
|
completedAt: new Date('2024-01-15T16:00:00Z'), // 01:00 Tokyo time
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ date: dailyCompletions.date })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const storedDates = new Set(rows.map((r) => r.date));
|
||||||
|
|
||||||
|
// Walk-back from LOCAL date "2024-01-16" finds all three entries
|
||||||
|
expect(calcStreak(storedDates, '2024-01-16')).toBe(3);
|
||||||
|
|
||||||
|
// Walk-back from UTC date "2024-01-15" misses the "2024-01-16" local entry
|
||||||
|
// (demonstrates the bug that was fixed: UTC walk-back gives wrong result)
|
||||||
|
expect(calcStreak(storedDates, '2024-01-15')).toBe(0);
|
||||||
|
|
||||||
|
await db.delete(dailyCompletions).where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user