mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-08-22 22:32:28 -04:00
Compare commits
4 Commits
99552c57ad
...
4a53e09ab3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a53e09ab3 | |||
| 2fb484ebaa | |||
| 21efdd4eef | |||
| be0d7ad297 |
@@ -64,6 +64,7 @@ See `.env.example`. Required/used variables:
|
|||||||
- **session** — `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
- **session** — `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
||||||
- **dailyVerses** — cached daily verse: `date` (unique), `bookId`, `verseText`, `reference`, `createdAt`
|
- **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.
|
- **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.
|
Sessions expire after 30 days and auto-renew when fewer than 15 days remain.
|
||||||
|
|
||||||
@@ -85,6 +86,15 @@ The `bibleBooks` array lists all 66 books with metadata:
|
|||||||
|
|
||||||
`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`.
|
`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`)
|
### Authentication (`src/lib/server/auth.ts`)
|
||||||
|
|
||||||
- Token: base64url-encoded random bytes; stored as a SHA-256 hash in the DB. Cookie name: `auth-session`.
|
- Token: base64url-encoded random bytes; stored as a SHA-256 hash in the DB. Cookie name: `auth-session`.
|
||||||
@@ -107,6 +117,7 @@ The `bibleBooks` array lists all 66 books with metadata:
|
|||||||
| `/progress` | Personal progress page (requires auth): activity calendar, 66-book grid with mastery tiers, insights, and achievements/milestones. |
|
| `/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. |
|
| `/stats` | Personal stats page (requires auth); returns `requiresAuth: true` for unauthenticated visitors and renders a sign-in modal. |
|
||||||
| `/dev` | Local-time / countdown debug page. |
|
| `/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
|
### API Endpoints
|
||||||
|
|
||||||
@@ -121,6 +132,10 @@ The `bibleBooks` array lists all 66 books with metadata:
|
|||||||
| `POST /api/similar-verses` | Semantic verse search via embeddings. |
|
| `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/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/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
|
### Other Endpoints
|
||||||
|
|
||||||
@@ -159,6 +174,9 @@ A streak counts consecutive calendar days (in the player's local timezone) on wh
|
|||||||
| `src/lib/server/bible-api.ts` | Random verse fetching on top of the XML parser |
|
| `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/bible.ts` | Bible book utility functions |
|
||||||
| `src/lib/server/milestones.ts` | Achievement/milestone calculation (set-completion, streak, etc.) |
|
| `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/types/bible.ts` | 66-book metadata and TypeScript types |
|
||||||
| `src/lib/utils/game.ts` | Guess evaluation and grading |
|
| `src/lib/utils/game.ts` | Guess evaluation and grading |
|
||||||
| `src/lib/utils/share.ts` | Share grid/text generation |
|
| `src/lib/utils/share.ts` | Share grid/text generation |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+1
-2
@@ -3,8 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+2
-2
@@ -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;
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
let fadeOutId: ReturnType<typeof setTimeout>;
|
let fadeOutId: ReturnType<typeof setTimeout>;
|
||||||
let fadeInId: ReturnType<typeof setTimeout>;
|
|
||||||
let changeId: ReturnType<typeof setTimeout>;
|
let changeId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
function animateTo(newText: string, delay = 0) {
|
function animateTo(newText: string, delay = 0) {
|
||||||
@@ -27,7 +26,6 @@
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(fadeOutId);
|
clearTimeout(fadeOutId);
|
||||||
clearTimeout(fadeInId);
|
|
||||||
clearTimeout(changeId);
|
clearTimeout(changeId);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
aria-label="Follow on Bluesky"
|
aria-label="Follow on Bluesky"
|
||||||
data-umami-event="Bluesky clicked"
|
data-umami-event="Bluesky clicked"
|
||||||
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
|
|
||||||
>
|
>
|
||||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||||
</a>
|
</a>
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
aria-label="Follow on Twitter"
|
aria-label="Follow on Twitter"
|
||||||
data-umami-event="Twitter clicked"
|
data-umami-event="Twitter clicked"
|
||||||
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
|
|
||||||
>
|
>
|
||||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||||
</a>
|
</a>
|
||||||
@@ -37,7 +35,6 @@
|
|||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
aria-label="Send email"
|
aria-label="Send email"
|
||||||
data-umami-event="Email clicked"
|
data-umami-event="Email clicked"
|
||||||
onclick={() => (window as any).rybbit?.event("Email clicked")}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
|
interface VerseDisplayData {
|
||||||
|
dailyVerse: {
|
||||||
|
date: string;
|
||||||
|
reference: string;
|
||||||
|
verseText: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
isWon,
|
isWon,
|
||||||
blurChapter = false,
|
blurChapter = false,
|
||||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
}: { data: VerseDisplayData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let displayReference = $derived(
|
let displayReference = $derived(
|
||||||
blurChapter
|
blurChapter
|
||||||
@@ -19,7 +26,7 @@
|
|||||||
);
|
);
|
||||||
let displayVerseText = $derived(
|
let displayVerseText = $derived(
|
||||||
dailyVerse.verseText
|
dailyVerse.verseText
|
||||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
.replace(/^([a-z])/, (c: string) => c.toUpperCase())
|
||||||
.replace(/[,:;-—]$/, "..."),
|
.replace(/[,:;-—]$/, "..."),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,7 +62,6 @@
|
|||||||
function copyVerse() {
|
function copyVerse() {
|
||||||
navigator.clipboard.writeText(displayVerseText).then(() => {
|
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||||
copied = true;
|
copied = true;
|
||||||
(window as any).rybbit?.event("Copy Verse");
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copied = false;
|
copied = false;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import CountdownTimer from "./CountdownTimer.svelte";
|
import CountdownTimer from "./CountdownTimer.svelte";
|
||||||
import StreakCounter from "./StreakCounter.svelte";
|
import StreakCounter from "./StreakCounter.svelte";
|
||||||
import ChapterGuess from "./ChapterGuess.svelte";
|
import ChapterGuess from "./ChapterGuess.svelte";
|
||||||
|
import SubmitVerse from "./SubmitVerse.svelte";
|
||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
solveRank: number;
|
solveRank: number;
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
streakPercentile = null,
|
streakPercentile = null,
|
||||||
isLoggedIn = false,
|
isLoggedIn = false,
|
||||||
anonymousId = "",
|
anonymousId = "",
|
||||||
|
localDate = "",
|
||||||
}: {
|
}: {
|
||||||
statsData: StatsData | null;
|
statsData: StatsData | null;
|
||||||
correctBookId: string;
|
correctBookId: string;
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
streakPercentile?: number | null;
|
streakPercentile?: number | null;
|
||||||
isLoggedIn?: boolean;
|
isLoggedIn?: boolean;
|
||||||
anonymousId?: string;
|
anonymousId?: string;
|
||||||
|
localDate?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -253,13 +256,9 @@
|
|||||||
: "Copy to Clipboard"}
|
: "Copy to Clipboard"}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (hasWebShare) {
|
if (hasWebShare) {
|
||||||
(window as any).rybbit?.event("Share");
|
|
||||||
shareResult(effectiveShareText);
|
shareResult(effectiveShareText);
|
||||||
} else {
|
} else {
|
||||||
if (!copyTracked) {
|
if (!copyTracked) {
|
||||||
(window as any).rybbit?.event(
|
|
||||||
"Copy to Clipboard",
|
|
||||||
);
|
|
||||||
copyTracked = true;
|
copyTracked = true;
|
||||||
}
|
}
|
||||||
clipboardCopy(effectiveShareText);
|
clipboardCopy(effectiveShareText);
|
||||||
@@ -288,7 +287,6 @@
|
|||||||
data-umami-event="Copy to Clipboard"
|
data-umami-event="Copy to Clipboard"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (!copyTracked) {
|
if (!copyTracked) {
|
||||||
(window as any).rybbit?.event("Copy to Clipboard");
|
|
||||||
copyTracked = true;
|
copyTracked = true;
|
||||||
}
|
}
|
||||||
clipboardCopy(effectiveShareText);
|
clipboardCopy(effectiveShareText);
|
||||||
@@ -413,11 +411,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Sign in with Google
|
Sign in with Google
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<SubmitVerse
|
||||||
|
{isLoggedIn}
|
||||||
|
{anonymousId}
|
||||||
|
{localDate}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="signin-prompt">
|
<div class="signin-prompt">
|
||||||
<a
|
<a
|
||||||
href="https://discord.gg/yWQXbGK8SD"
|
href="https://discord.gg/yWQXbGK8SD"
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { testDb as db } from '$lib/server/db/test';
|
import { testDb as db } from '$lib/server/db/test';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
@@ -64,15 +64,15 @@ export async function invalidateSession(sessionId: string) {
|
|||||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
|
||||||
event.cookies.set(sessionCookieName, token, {
|
cookies.set(sessionCookieName, token, {
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
|
||||||
event.cookies.delete(sessionCookieName, {
|
cookies.delete(sessionCookieName, {
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { Cookies, RequestEvent } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
@@ -64,15 +64,15 @@ export async function invalidateSession(sessionId: string) {
|
|||||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
|
||||||
event.cookies.set(sessionCookieName, token, {
|
cookies.set(sessionCookieName, token, {
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
|
||||||
event.cookies.delete(sessionCookieName, {
|
cookies.delete(sessionCookieName, {
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'> = {
|
||||||
|
|||||||
@@ -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,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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,15 +53,7 @@ export function createGamePersistence(
|
|||||||
if ((window as any).umami) {
|
if ((window as any).umami) {
|
||||||
(window as any).umami.identify(anonymousId);
|
(window as any).umami.identify(anonymousId);
|
||||||
}
|
}
|
||||||
if (user) {
|
|
||||||
const nameParts = [user.firstName, user.lastName].filter(Boolean);
|
|
||||||
(window as any).rybbit?.identify(user.id, {
|
|
||||||
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
|
|
||||||
...(user.email ? { email: user.email } : {}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
(window as any).rybbit?.identify(anonymousId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = getDate();
|
const date = getDate();
|
||||||
const reference = getReference();
|
const reference = getReference();
|
||||||
|
|||||||
@@ -146,7 +146,6 @@
|
|||||||
(window as any).umami
|
(window as any).umami
|
||||||
) {
|
) {
|
||||||
(window as any).umami.track("First guess");
|
(window as any).umami.track("First guess");
|
||||||
(window as any).rybbit?.event("First guess");
|
|
||||||
localStorage.setItem(key, "true");
|
localStorage.setItem(key, "true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,9 +286,6 @@
|
|||||||
(window as any).umami.track("Guessed correctly", {
|
(window as any).umami.track("Guessed correctly", {
|
||||||
totalGuesses: persistence.guesses.length,
|
totalGuesses: persistence.guesses.length,
|
||||||
});
|
});
|
||||||
(window as any).rybbit?.event("Guessed correctly", {
|
|
||||||
totalGuesses: persistence.guesses.length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -436,6 +432,7 @@
|
|||||||
{streakPercentile}
|
{streakPercentile}
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={!!user}
|
||||||
anonymousId={persistence.anonymousId}
|
anonymousId={persistence.anonymousId}
|
||||||
|
localDate={new Date().toLocaleDateString("en-CA")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { bibleBooks } from '$lib/server/bible';
|
||||||
|
import { getChapterCount, getVerseCount } from '$lib/server/xml-bible';
|
||||||
|
|
||||||
|
// Static Bible structure for the cascading Book → Chapter → Verse selectors.
|
||||||
|
// ~1189 chapter entries total; safe to cache indefinitely.
|
||||||
|
let cached: { bookId: string; chapters: number[] }[] | null = null;
|
||||||
|
|
||||||
|
function build(): { bookId: string; chapters: number[] }[] {
|
||||||
|
if (cached) return cached;
|
||||||
|
const out = bibleBooks.map((book) => {
|
||||||
|
const chapterCount = getChapterCount(book.order);
|
||||||
|
const chapters: number[] = new Array(chapterCount);
|
||||||
|
for (let c = 1; c <= chapterCount; c++) {
|
||||||
|
chapters[c - 1] = getVerseCount(book.order, c);
|
||||||
|
}
|
||||||
|
return { bookId: book.id, chapters };
|
||||||
|
});
|
||||||
|
cached = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
return json(build());
|
||||||
|
};
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyVerses, verseSubmissions, user } from '$lib/server/db/schema';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
import {
|
||||||
|
getChapterCount,
|
||||||
|
getVerseCount,
|
||||||
|
composeVerseWindow,
|
||||||
|
formatWindowReference
|
||||||
|
} from '$lib/server/xml-bible';
|
||||||
|
|
||||||
|
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
|
||||||
|
const MAX_SCAN_RETRIES = 5;
|
||||||
|
|
||||||
|
// Dev-only: insert a verse_submissions row (from a random existing account) +
|
||||||
|
// its corresponding daily_verses row, scheduled on the earliest valid future
|
||||||
|
// date per the spec's scheduling rules. Lets you populate /scheduled-verses to
|
||||||
|
// verify the admin view without going through the (Step 5+) submit API.
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const host = request.headers.get('host') ?? '';
|
||||||
|
if (!DEV_HOSTS.includes(host)) {
|
||||||
|
return json({ error: 'Not allowed in production' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a random existing user.
|
||||||
|
const users = await db.select().from(user);
|
||||||
|
if (users.length === 0) {
|
||||||
|
return json(
|
||||||
|
{ error: 'No users exist yet. Sign up at least one account first.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const submitter = users[Math.floor(Math.random() * users.length)];
|
||||||
|
|
||||||
|
// Pick a random valid (book, chapter, verse).
|
||||||
|
const book = bibleBooks[Math.floor(Math.random() * bibleBooks.length)];
|
||||||
|
const chapterCount = getChapterCount(book.order);
|
||||||
|
const chapter = Math.floor(Math.random() * chapterCount) + 1;
|
||||||
|
const verseCount = getVerseCount(book.order, chapter);
|
||||||
|
const verse = Math.floor(Math.random() * verseCount) + 1;
|
||||||
|
|
||||||
|
const window = composeVerseWindow(book.order, chapter, verse);
|
||||||
|
if (!window) {
|
||||||
|
return json({ error: 'Failed to compose verse window' }, { status: 500 });
|
||||||
|
}
|
||||||
|
const reference = formatWindowReference(
|
||||||
|
window.bookName,
|
||||||
|
window.startChapter,
|
||||||
|
window.startVerse,
|
||||||
|
window.endChapter,
|
||||||
|
window.endVerse
|
||||||
|
);
|
||||||
|
const verseText = window.verses.join(' ');
|
||||||
|
|
||||||
|
const todayUtc = new Date().toISOString().slice(0, 10);
|
||||||
|
const tomorrowMs = new Date(todayUtc + 'T00:00:00Z').getTime() + 86400000;
|
||||||
|
|
||||||
|
function addDays(ms: number, n: number): string {
|
||||||
|
return new Date(ms + n * 86400000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all future daily_verses into memory (date → bookId, date → reference).
|
||||||
|
const future = await db.select().from(dailyVerses).where(sql`date > ${todayUtc}`);
|
||||||
|
const byDate = new Map<string, { bookId: string; reference: string }>();
|
||||||
|
for (const r of future) byDate.set(r.date, { bookId: r.bookId, reference: r.reference });
|
||||||
|
|
||||||
|
// For the 60-day repeat rule, also need a bounded historical lookup per
|
||||||
|
// candidate date. Build a set of all existing references globally to do a
|
||||||
|
// cheap pre-check, then a precise range query when we settle on a date.
|
||||||
|
const allRows = await db.select().from(dailyVerses);
|
||||||
|
const refDates = new Map<string, string[]>(); // reference → list of dates
|
||||||
|
for (const r of allRows) {
|
||||||
|
const arr = refDates.get(r.reference) ?? [];
|
||||||
|
arr.push(r.date);
|
||||||
|
refDates.set(r.reference, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scheduledDate: string | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < MAX_SCAN_RETRIES && !scheduledDate; attempt++) {
|
||||||
|
// Walk day-by-day from tomorrow.
|
||||||
|
for (let offset = 0; offset < 10000; offset++) {
|
||||||
|
const D = addDays(tomorrowMs, offset);
|
||||||
|
|
||||||
|
// Rule 1: empty (no committed daily_verses row).
|
||||||
|
if (byDate.has(D)) continue;
|
||||||
|
|
||||||
|
// Rule 2: no back-to-back with prior neighbor.
|
||||||
|
const prev = byDate.get(addDays(tomorrowMs, offset - 1));
|
||||||
|
if (prev && prev.bookId === book.id) continue;
|
||||||
|
|
||||||
|
// Rule 3: no back-to-back with next committed neighbor.
|
||||||
|
const next = byDate.get(addDays(tomorrowMs, offset + 1));
|
||||||
|
if (next && next.bookId === book.id) continue;
|
||||||
|
|
||||||
|
// Rule 4: 60-day repeat distance — no same reference within ±60 days.
|
||||||
|
const sameRefDates = refDates.get(reference) ?? [];
|
||||||
|
const dMs = new Date(D + 'T00:00:00Z').getTime();
|
||||||
|
const tooClose = sameRefDates.some((d) => {
|
||||||
|
const diff = Math.abs(new Date(d + 'T00:00:00Z').getTime() - dMs);
|
||||||
|
return diff <= 60 * 86400000;
|
||||||
|
});
|
||||||
|
if (tooClose) continue;
|
||||||
|
|
||||||
|
scheduledDate = D;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!scheduledDate) break; // exhausted retries
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scheduledDate) {
|
||||||
|
return json({ error: 'Could not find a valid future date' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert both rows in a transaction. On a unique conflict (concurrent
|
||||||
|
// submission grabbed the same date), bubble up so the caller can retry.
|
||||||
|
const now = Date.now();
|
||||||
|
const verseId = Bun.randomUUIDv7();
|
||||||
|
const submissionId = Bun.randomUUIDv7();
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.insert(dailyVerses)
|
||||||
|
.values({
|
||||||
|
id: verseId,
|
||||||
|
date: scheduledDate!,
|
||||||
|
bookId: book.id,
|
||||||
|
verseText,
|
||||||
|
reference,
|
||||||
|
createdAt: sql`${Math.floor(now / 1000)}`
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
tx.insert(verseSubmissions)
|
||||||
|
.values({
|
||||||
|
id: submissionId,
|
||||||
|
userId: submitter.id,
|
||||||
|
scheduledDate: scheduledDate!,
|
||||||
|
selectedBookId: book.id,
|
||||||
|
selectedChapter: chapter,
|
||||||
|
selectedVerse: verse,
|
||||||
|
submittedAt: now
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
|
||||||
|
return json(
|
||||||
|
{ error: 'Date collision during seed; retry the request', detail: String(err?.message ?? err) },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
submitter: { id: submitter.id, email: submitter.email },
|
||||||
|
selected: {
|
||||||
|
bookId: book.id,
|
||||||
|
bookName: book.name,
|
||||||
|
chapter,
|
||||||
|
verse
|
||||||
|
},
|
||||||
|
scheduledDate,
|
||||||
|
reference,
|
||||||
|
verseText
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convenience: GET returns the current count of submissions + future scheduled
|
||||||
|
// verses, useful as a quick dev sanity check without mutating state.
|
||||||
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
const host = request.headers.get('host') ?? '';
|
||||||
|
if (!DEV_HOSTS.includes(host)) {
|
||||||
|
return json({ error: 'Not allowed in production' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const subs = await db.select().from(verseSubmissions);
|
||||||
|
const future = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyVerses)
|
||||||
|
.where(sql`date > ${new Date().toISOString().slice(0, 10)}`);
|
||||||
|
return json({
|
||||||
|
totalSubmissions: subs.length,
|
||||||
|
futureScheduledVerses: future.length
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,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
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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, '/');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const GET: RequestHandler = async ({ url, 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,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,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user