mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-08-22 22:32:28 -04:00
Compare commits
100 Commits
rss
...
4a53e09ab3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a53e09ab3 | |||
| 2fb484ebaa | |||
| 21efdd4eef | |||
| be0d7ad297 | |||
| 99552c57ad | |||
| efc9900de1 | |||
| e3ca264c54 | |||
| f5e16c7e71 | |||
| e45ac28169 | |||
| 3d578a9eb8 | |||
| db04da6a2c | |||
| 321fac9aa8 | |||
| 4a5aef5a3d | |||
| f98ab24d2e | |||
| c5b333bbb3 | |||
| e842923d81 | |||
| 51bfb53a39 | |||
| 45d33b6bad | |||
| 3eb3a968dc | |||
| 67d9757f98 | |||
| b6b41b6ba9 | |||
| bdc08bc58e | |||
| 83cfcc66c0 | |||
| e878dea235 | |||
| 252edc3a6d | |||
| 75b13280ef | |||
| 7007df2966 | |||
| 61673a646d | |||
| 1eb8eb2f04 | |||
| ae4482a551 | |||
| 884bbe65c7 | |||
| 3de55ba216 | |||
| 6e74fffb65 | |||
| 1ae2b2ac6c | |||
| a188be167b | |||
| e550965086 | |||
| 03429b17cc | |||
| 3ee7331510 | |||
| 592fa917cd | |||
| ad1774e6b0 | |||
| e1a665ba63 | |||
| f3c9feaf97 | |||
| a5cf248e29 | |||
| f9f3f3de12 | |||
| abab886d1a | |||
| acc82af7cd | |||
| fc674d6008 | |||
| 087a476df8 | |||
| ba45cbdc37 | |||
| 1de436534c | |||
| 3bcd7ea266 | |||
| 7ecc84ffbc | |||
| 3d78353a90 | |||
| bd36f29419 | |||
| 3036264d44 | |||
| 6554ef8f41 | |||
| c3307b3920 | |||
| 19646c72ca | |||
| e592751a1c | |||
| 77cc83841d | |||
| e8b2d2e35e | |||
| c50cccd3d3 | |||
| 638a789a0f | |||
| e815e15ce5 | |||
| e6081c28f1 | |||
| 2de4e9e2a7 | |||
| ea7a848125 | |||
| 1719e0bbbf | |||
| 885adad756 | |||
| 1b96919acd | |||
| 8ef2a41a69 | |||
| ac6ec051d4 | |||
| a12c7d011a | |||
| 77ffd6fbee | |||
| f6652e59a7 | |||
| 290fb06fe9 | |||
| df8a9e62bb | |||
| 730b65201a | |||
| 78440cfbc3 | |||
| 482ee0a83a | |||
| 342bd323a1 | |||
| 95725ab4fe | |||
| 06ff0820ce | |||
| 3cf95152e6 | |||
| c04899d419 | |||
| 6161ef75a1 | |||
| 9d7399769a | |||
| b1591229ba | |||
| 96024d5048 | |||
| 86f81cf9dd | |||
| 24a5fdbb80 | |||
| dfe1c40a8a | |||
| dfe784b744 | |||
| 6bced13543 | |||
| 7d93ead70c | |||
| 4c82aa078b | |||
| 2058149207 | |||
| 9406498cc9 | |||
| 2bd86d37a1 | |||
| 33d6fae446 |
@@ -1,4 +1,11 @@
|
|||||||
DATABASE_URL=example.db
|
DATABASE_URL=example.db
|
||||||
|
|
||||||
|
# Cron job secret for protected endpoints (e.g. send-daily-verse)
|
||||||
|
CRON_SECRET=your-cron-secret-here
|
||||||
|
|
||||||
|
# Discord webhook URL for posting the daily verse
|
||||||
|
DISCORD_DAILY_WEBHOOK=https://discord.com/api/webhooks/your-webhook-url
|
||||||
|
|
||||||
PUBLIC_SITE_URL=https://bibdle.com
|
PUBLIC_SITE_URL=https://bibdle.com
|
||||||
|
|
||||||
# nodemailer
|
# nodemailer
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ llms-*
|
|||||||
embeddings*
|
embeddings*
|
||||||
*bible.xml
|
*bible.xml
|
||||||
engwebu_usfx.xml
|
engwebu_usfx.xml
|
||||||
|
|
||||||
|
deploy.log
|
||||||
|
bibdle.socket
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
EnglishNKJBible.xml
|
|
||||||
GreekModern1904Bible.xml
|
|
||||||
engwebu_usfx.xml
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
|
||||||
- **Styling**: Tailwind CSS 4
|
|
||||||
- **Database**: SQLite with Drizzle ORM
|
|
||||||
- **Auth**: Session-based authentication using @oslojs/crypto (SHA-256 hashed tokens)
|
|
||||||
- **Deployment**: Node.js adapter for production builds
|
|
||||||
- **External API**: bible-api.com for fetching random verses
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start development server
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Type checking
|
|
||||||
npm run check
|
|
||||||
npm run check:watch
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Preview production build
|
|
||||||
npm run preview
|
|
||||||
|
|
||||||
# Database operations
|
|
||||||
npm run db:push # Push schema changes to database
|
|
||||||
npm run db:generate # Generate migrations
|
|
||||||
npm run db:migrate # Run migrations
|
|
||||||
npm run db:studio # Open Drizzle Studio GUI
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema (`src/lib/server/db/schema.ts`)
|
|
||||||
|
|
||||||
- **user**: User accounts with id and age
|
|
||||||
- **session**: Auth sessions linked to users with expiration timestamps
|
|
||||||
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
|
||||||
|
|
||||||
Sessions expire after 30 days and are automatically renewed when less than 15 days remain.
|
|
||||||
|
|
||||||
### Bible Data (`src/lib/types/bible.ts`)
|
|
||||||
|
|
||||||
The `bibleBooks` array contains all 66 Bible books with metadata:
|
|
||||||
- Testament (old/new)
|
|
||||||
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
|
||||||
- Order (1-66, used for adjacency detection)
|
|
||||||
- Popularity (2-10, affects grading - higher is more popular)
|
|
||||||
|
|
||||||
### Daily Verse System (`src/routes/+page.server.ts`)
|
|
||||||
|
|
||||||
The `getTodayVerse()` function:
|
|
||||||
1. Checks database for existing verse for today's date
|
|
||||||
2. If none exists, fetches from bible-api.com (random verse + 2 consecutive verses)
|
|
||||||
3. Caches in database with UTC date key
|
|
||||||
4. Returns verse with book metadata for the game
|
|
||||||
|
|
||||||
### Game Logic (`src/routes/+page.svelte`)
|
|
||||||
|
|
||||||
**State Management:**
|
|
||||||
- `guesses` array stores game state in localStorage keyed by date
|
|
||||||
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
|
|
||||||
- `isWon` is derived from whether any guess matches the correct book
|
|
||||||
|
|
||||||
**Grading System:**
|
|
||||||
```javascript
|
|
||||||
// Grade formula combines performance + difficulty
|
|
||||||
performanceScore = max(0, 10 - numGuesses)
|
|
||||||
difficulty = 14 - popularity
|
|
||||||
totalScore = performanceScore + difficulty * 0.8
|
|
||||||
|
|
||||||
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hint System:**
|
|
||||||
- ✅ Green checkmark: Exact match
|
|
||||||
- 🟩 Green square: Section matches
|
|
||||||
- 🟧 Orange square: Testament matches (shared results)
|
|
||||||
- ‼️ Double exclamation: Adjacent book in Bible order
|
|
||||||
- 🟥 Red square: No match
|
|
||||||
|
|
||||||
### Authentication System (`src/lib/server/auth.ts`)
|
|
||||||
|
|
||||||
- Token-based sessions with SHA-256 hashing
|
|
||||||
- Cookies store session tokens, validated on each request
|
|
||||||
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
|
|
||||||
- Note: Currently the schema includes user table but auth UI is not yet implemented
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- `src/routes/+page.svelte` - Main game UI and client-side logic
|
|
||||||
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
|
|
||||||
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
|
|
||||||
- `src/lib/server/bible.ts` - Bible book utility functions
|
|
||||||
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
|
|
||||||
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
|
|
||||||
- `src/hooks.server.ts` - SvelteKit server hook for session validation
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Required in `.env`:
|
|
||||||
- `DATABASE_URL` - Path to SQLite database file (e.g., `./local.db`)
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
The project uses `@sveltejs/adapter-node` for deployment. The build output is a Node.js server that can be run with systemd or similar process managers. See `bibdle.service` and `bibdle.socket` for systemd configuration.
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) - not stores or reactive declarations
|
|
||||||
- The schema includes authentication tables but the login/signup UI is not yet implemented
|
|
||||||
- Daily verses are cached permanently in the database to ensure consistency
|
|
||||||
- LocalStorage persists guesses per day using the key pattern `bibdle-guesses-${date}`
|
|
||||||
- The game validates book IDs from the API against the hardcoded `bibleBooks` array
|
|
||||||
@@ -1,38 +1,195 @@
|
|||||||
# sv
|
# Bibdle
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
A daily Bible verse guessing game. Each day a verse is shown and players try to identify which of the 66 books of the Bible it comes from, receiving Wordle-style feedback (Testament match, Section match, Adjacent book, First letter, etc.) after each guess. A new verse is generated daily and progress is tracked across timezones.
|
||||||
|
|
||||||
## Creating a project
|
Live at [bibdle.com](https://bibdle.com).
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
## Tech Stack
|
||||||
|
|
||||||
```sh
|
- **Framework**: SvelteKit 2 with Svelte 5 (runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||||
# create a new project in the current directory
|
- **Styling**: Tailwind CSS 4
|
||||||
npx sv create
|
- **Database**: SQLite (`bun:sqlite`) with Drizzle ORM
|
||||||
|
- **Auth**: Session-based — email/password (argon2id via `Bun.password`), plus Apple and Google OAuth
|
||||||
|
- **Bible text**: Local NKJV XML (`EnglishNKJBible.xml`), parsed with `fast-xml-parser`; a Greek 1904 and Swedish 2000 translation are also bundled for alternate modes
|
||||||
|
- **ML** (currently disabled): `@xenova/transformers` verse embeddings for a similarity search route
|
||||||
|
- **Deployment**: `@sveltejs/adapter-node`, run under Bun, managed by a systemd service (see `bibdle.service`)
|
||||||
|
|
||||||
# create a new project in my-app
|
## Getting Started
|
||||||
npx sv create my-app
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Start the dev server (Vite, Bun runtime)
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
bun run check
|
||||||
|
|
||||||
|
# Tests (Bun test)
|
||||||
|
bun test
|
||||||
|
bun test tests/timezone-handling.test.ts # single file
|
||||||
|
bun test --watch
|
||||||
|
|
||||||
|
# Production build & preview
|
||||||
|
bun run build
|
||||||
|
bun run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
### Database
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
```bash
|
||||||
|
bun run db:push # push schema changes directly (avoid in prod)
|
||||||
```sh
|
bun run db:generate # generate migrations
|
||||||
npm run dev
|
bun run db:migrate # run migrations
|
||||||
|
bun run db:studio # open Drizzle Studio GUI
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
### Environment Variables
|
||||||
|
|
||||||
To create a production version of your app:
|
See `.env.example`. Required/used variables:
|
||||||
|
|
||||||
```sh
|
- `DATABASE_URL` — path to the SQLite database file (e.g. `prod.db`)
|
||||||
npm run build
|
- `PUBLIC_SITE_URL` — canonical site URL
|
||||||
```
|
- `CRON_SECRET` — bearer token protecting the cron-only `/api/send-daily-verse` endpoint
|
||||||
|
- `DISCORD_DAILY_WEBHOOK` — webhook for posting the daily verse to Discord
|
||||||
|
- `AUTH_SECRET` — secret for Apple Sign-In
|
||||||
|
- `APPLE_ID` / `APPLE_TEAM_ID` / `APPLE_KEY_ID` / `APPLE_PRIVATE_KEY` — Apple Sign-In credentials
|
||||||
|
- `SMTP_USERNAME` / `SMTP_TOKEN` / `SMTP_SERVER` / `SMTP_PORT` — email (nodemailer)
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
## Architecture
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
### Database Schema (`src/lib/server/db/schema.ts`)
|
||||||
|
|
||||||
|
- **user** — `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `googleId` (unique), `isPrivate`
|
||||||
|
- **session** — `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
||||||
|
- **dailyVerses** — cached daily verse: `date` (unique), `bookId`, `verseText`, `reference`, `createdAt`
|
||||||
|
- **dailyCompletions** — one row per player/date: `anonymousId`, `date`, `guessCount`, `guesses` (JSON of book IDs, nullable), `completedAt`. Unique on `(anonymousId, date)` to prevent duplicate submissions.
|
||||||
|
- **verseSubmissions** — a log of community-submitted future verses: `id`, `userId` (FK → `user.id` ON DELETE SET NULL), `scheduledDate` (unique `YYYY-MM-DD` matching a `dailyVerses` row), `selectedBookId`, `selectedChapter`, `selectedVerse` (the anchor the user picked), `submittedAt` (server UTC millis, drives the 7-day cooldown). Indexed on `userId` (cooldown lookup) and `scheduledDate` (unique). The canonical verse text/reference lives on `dailyVerses`, joined by `scheduledDate`.
|
||||||
|
|
||||||
|
Sessions expire after 30 days and auto-renew when fewer than 15 days remain.
|
||||||
|
|
||||||
|
### Bible Data (`src/lib/types/bible.ts`)
|
||||||
|
|
||||||
|
The `bibleBooks` array lists all 66 books with metadata:
|
||||||
|
|
||||||
|
- `testament`: `old` | `new`
|
||||||
|
- `section`: `Law`, `History`, `Wisdom`, `Major Prophets`, `Minor Prophets`, `Gospels`, `Pauline Epistles`, `General Epistles`, `Apocalyptic`
|
||||||
|
- `order` (1–66, used for adjacency detection)
|
||||||
|
|
||||||
|
### Game Logic
|
||||||
|
|
||||||
|
- `src/lib/utils/game.ts` — `evaluateGuess()` compares a guess to the target book and returns `testamentMatch`, `sectionMatch`, `adjacent`, and `firstLetterMatch` flags. `getGrade()` maps guess count to a letter grade (S+ down to C). Includes a special-case so that numbered Epistles (e.g. "1 John") match on first letter against any other numbered Epistle.
|
||||||
|
- `src/lib/stores/game-persistence.svelte.ts` — reactive store that keeps guesses and per-day flags in sync with `localStorage`, keyed by date (`bibdle-guesses-${date}`). Resolves the player identity (logged-in user ID, or a locally generated anonymous UUID) and restores state.
|
||||||
|
- `src/lib/utils/share.ts` — generates the share grid and text. Hint emojis: ✅ exact · 🟩 section · 🟧 testament · ‼️ adjacent · 🟥 no match.
|
||||||
|
|
||||||
|
### Daily Verse System
|
||||||
|
|
||||||
|
`src/lib/server/daily-verse.ts` → `getVerseForDate(date)`: returns the cached verse for a date if present, otherwise fetches a random verse from the local XML Bible and stores it permanently. The XML Bible is read and parsed in `src/lib/server/xml-bible.ts`; `src/lib/server/bible-api.ts` wraps it to produce a verse with a validated `bookId`, `reference`, and `verseText`.
|
||||||
|
|
||||||
|
### Community Verse Submissions
|
||||||
|
|
||||||
|
Authenticated users who have solved today's puzzle can submit a verse for scheduling as a future "verse of the day." Selection is dropdown-based (cascading Book → Chapter → Verse selects), so content is always canonical NKJV text — no free-text entry. Each submission immediately reserves a concrete future date.
|
||||||
|
|
||||||
|
- `src/lib/server/verse-submission.ts` — the 3-verse window composer (`composeVerseWindow`, fall-forward, never crosses a book), `formatWindowReference` (same-chapter hyphen / cross-chapter en-dash), and the assign-at-submit scheduling scan (empty / no-back-to-back-with-committed-neighbors / 60-day-repeat rules, transaction + retry-on-unique-conflict).
|
||||||
|
- `getVerseForDate(date)` serves pre-written submission rows unchanged on their day; gap days (no committed row) are filled lazily by the random path, now extended to avoid the book of any committed neighbor (D-1 / D+1).
|
||||||
|
- **Rate limiting:** one submission per user per rolling 7×24h window, measured in server UTC (not gameable). The win-screen button is always visible; it is greyed out with a countdown timer while the cooldown is active.
|
||||||
|
- **Attribution is anonymous everywhere** except the admin-only `/scheduled-verses` view (the single exception, gated to `ADMIN_EMAIL` in `src/lib/server/admin.ts`), which surfaces submitter email for moderation.
|
||||||
|
|
||||||
|
### Authentication (`src/lib/server/auth.ts`)
|
||||||
|
|
||||||
|
- Token: base64url-encoded random bytes; stored as a SHA-256 hash in the DB. Cookie name: `auth-session`.
|
||||||
|
- Anonymous users are identified by a client-generated UUID in `localStorage`. On sign-up, `migrateAnonymousStats()` re-attributes the user's `dailyCompletions` rows from the anonymous ID to the new user ID (duplicates for overlapping dates are dropped).
|
||||||
|
- Apple Sign-In (`src/lib/server/apple-auth.ts`) and Google Sign-In (`src/lib/server/google-auth.ts`) are supported; the SvelteKit CSRF config trusts `https://appleid.apple.com` for the cross-origin `form_post` callback.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | Main game (classic mode). Verse display, search input, guesses table, win screen, streak/percentile. `+page.ts` sets `ssr = false` so the load runs client-side with the true local date, then POSTs that date to `/api/daily-verse`. |
|
||||||
|
| `/imposter` | Imposter Mode — four verses are shown; three come from one book and one from a different book. Pick the one that doesn't belong. |
|
||||||
|
| `/random` | Debug page showing a random verse from the NKJV. |
|
||||||
|
| `/greek-random` | Debug page showing a random verse in parallel Greek (1904) / English (NKJV). |
|
||||||
|
| `/similarity` | Search a sentence and return the most similar Bible verses via the (currently disabled) embeddings model. |
|
||||||
|
| `/about` | About page with the project's backstory and social links. |
|
||||||
|
| `/global` | Public stats dashboard: completions today/all-time, unique & weekly & monthly players, active streak distribution, 14-day completions trend, retention/return-rate metrics. |
|
||||||
|
| `/progress` | Personal progress page (requires auth): activity calendar, 66-book grid with mastery tiers, insights, and achievements/milestones. |
|
||||||
|
| `/stats` | Personal stats page (requires auth); returns `requiresAuth: true` for unauthenticated visitors and renders a sign-in modal. |
|
||||||
|
| `/dev` | Local-time / countdown debug page. |
|
||||||
|
| `/scheduled-verses` | Admin-only (gated to `ADMIN_EMAIL` in `src/lib/server/admin.ts`). Full historical + future log of `verseSubmissions` joined to `dailyVerses` and `user`, sorted by scheduled date. The sole surface where submitter identity is shown. Not linked from the UI. |
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/daily-verse` | Fetch (and cache) the verse for a given `YYYY-MM-DD` date. |
|
||||||
|
| `POST /api/submit-completion` | Submit a game result (`anonymousId`, `date`, `guessCount`, `guesses`); returns solve rank, guess rank, total solves, average guesses, ties, and percentile. Unique on `(anonymousId, date)`. |
|
||||||
|
| `GET /api/streak?anonymousId=X&localDate=Y` | Current streak: walks backwards from the client's local date through completed dates, counting consecutive days. Single-day streaks are reported as 0 (minimum displayed streak is 2). |
|
||||||
|
| `GET /api/streak-percentile?streak=N&localDate=Y` | Streak percentile ranking computed across all players' current streaks. |
|
||||||
|
| `GET /api/stats` | Aggregated stats used by the `/global` dashboard. |
|
||||||
|
| `GET /api/imposter` | Generates a four-verse imposter-mode round. |
|
||||||
|
| `POST /api/similar-verses` | Semantic verse search via embeddings. |
|
||||||
|
| `POST /api/send-daily-verse` | Cron-only (bearer `CRON_SECRET`); posts today's verse to the Discord webhook. |
|
||||||
|
| `POST /api/dev/seed-history` | Dev seeding helper. |
|
||||||
|
| `POST /api/submit-verse` | Auth required. Accept `{ bookId, chapter, verse, localDate }`; runs the solved-today gate, 7-day cooldown, structural validation, and the assign-at-submit scheduling scan; returns `{ scheduledDate, reference, windowText }`. |
|
||||||
|
| `GET /api/submit-verse/status?localDate=YYYY-MM-DD` | Auth required. Win-screen button state: `canSubmit`, `cooldownEndsAt`, `lastSubmission`, and this user's not-yet-reached upcoming submissions. |
|
||||||
|
| `GET /api/bible/structure` | Public. 66-book verse counts per chapter (cascading-dropdown payload, cached indefinitely). |
|
||||||
|
| `GET /api/verse-window?bookId=gen&chapter=1&verse=1` | Public. Live 3-verse preview (fall-forward window) for the submit selector. |
|
||||||
|
|
||||||
|
### Other Endpoints
|
||||||
|
|
||||||
|
- `GET /feed.xml` — RSS feed of daily verses.
|
||||||
|
- `GET /sitemap.xml` — XML sitemap for SEO.
|
||||||
|
|
||||||
|
## Critical: Date/Time Handling
|
||||||
|
|
||||||
|
Bibdle is played across many timezones. The verse shown must always be the verse for the calendar date at **the player's location** — never the server's timezone, never UTC.
|
||||||
|
|
||||||
|
- The client computes today's date with `new Date().toLocaleDateString("en-CA")` (`YYYY-MM-DD`) and sends it to the server.
|
||||||
|
- Server-side date arithmetic always uses UTC methods on the client-provided date string (`new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`) to avoid timezone drift — see `/api/streak` and `/api/send-daily-verse`.
|
||||||
|
- `/` has `ssr = false` so the load runs client-side with the real local date.
|
||||||
|
- The main page also reloads itself if the tab regains focus on a new calendar day.
|
||||||
|
- The user's local date is never placed in the URL; it is only ever sent to API routes.
|
||||||
|
|
||||||
|
### Streak Calculation
|
||||||
|
|
||||||
|
A streak counts consecutive calendar days (in the player's local timezone) on which the puzzle was completed:
|
||||||
|
|
||||||
|
- The client passes `localDate`; the server never uses its own clock.
|
||||||
|
- `/api/streak` walks backwards from `localDate` through `dailyCompletions`, counting each completed day; stops at the first missing day. It's called from the win screen, so today is typically completed. Single-day streaks are reported as `0` — the minimum displayed streak is 2.
|
||||||
|
- `/api/streak-percentile` (which ranks all players) anchors on today-if-played-else-yesterday, so a player's streak isn't zeroed mid-day before they've had a chance to complete today's puzzle.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/routes/+page.svelte` | Main game UI and client-side logic |
|
||||||
|
| `src/routes/+page.server.ts` / `+page.ts` | Server load (user/session) + client load (`ssr: false`, fetches the daily verse) |
|
||||||
|
| `src/routes/+layout.svelte` | App shell, title animation, theme toggle, analytics injection |
|
||||||
|
| `src/lib/server/auth.ts` | Session management, password hashing, anonymous→user migration |
|
||||||
|
| `src/lib/server/apple-auth.ts`, `google-auth.ts` | OAuth providers |
|
||||||
|
| `src/lib/server/daily-verse.ts` | Per-date verse caching/lookup |
|
||||||
|
| `src/lib/server/xml-bible.ts` | Local XML Bible parsing (NKJV / Greek / Swedish) |
|
||||||
|
| `src/lib/server/bible-api.ts` | Random verse fetching on top of the XML parser |
|
||||||
|
| `src/lib/server/bible.ts` | Bible book utility functions |
|
||||||
|
| `src/lib/server/milestones.ts` | Achievement/milestone calculation (set-completion, streak, etc.) |
|
||||||
|
| `src/lib/server/admin.ts` | `ADMIN_EMAIL` constant for the `/scheduled-verses` admin route. |
|
||||||
|
| `src/lib/server/verse-submission.ts` | Window composer + scheduling scan for community verse submissions. |
|
||||||
|
| `src/lib/components/SubmitVerse.svelte` | Win-screen submit button (logged-out sign-in dropdown / cooldown + countdown / cascading selects + preview + submit). |
|
||||||
|
| `src/lib/types/bible.ts` | 66-book metadata and TypeScript types |
|
||||||
|
| `src/lib/utils/game.ts` | Guess evaluation and grading |
|
||||||
|
| `src/lib/utils/share.ts` | Share grid/text generation |
|
||||||
|
| `src/lib/utils/streak.ts`, `stats-client.ts`, `stats.ts` | Client-side streak/stats fetching and formatting |
|
||||||
|
| `src/lib/stores/game-persistence.svelte.ts` | Reactive localStorage-backed game state |
|
||||||
|
| `src/lib/server/db/schema.ts` | Drizzle ORM schema |
|
||||||
|
| `src/hooks.server.ts` | Session validation hook; (commented-out) embeddings init |
|
||||||
|
| `tests/` | Bun test suites: timezone, game, bible, stats, share, sign-in migration |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Production uses `@sveltejs/adapter-node` run under Bun via a systemd service. `deploy.sh` pulls latest, installs deps, builds, and restarts `bibdle.service` (which runs `bun --bun build/index.js` on port 5173 with `DATABASE_URL=prod.db`). See `bibdle.service` for the unit file.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Bibdle was created as a small, daily nudge to read the Bible — inspired by the Wordle story of a personal project that grew organically through word of mouth. The full backstory lives on the `/about` page.
|
||||||
|
|||||||
+7
-6
@@ -1,16 +1,17 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Bibdle SvelteKit App
|
Description=Bibdle SvelteKit App
|
||||||
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
||||||
Requires=bibdle.socket
|
After=network-online.target
|
||||||
After=network-online.target bibdle.socket
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=ORIGIN=https://bibdle.com
|
Environment=ORIGIN=https://bibdle.com
|
||||||
Environment=DATABASE_URL=local.db
|
Environment=DATABASE_URL=prod.db
|
||||||
Environment=IDLE_TIMEOUT=60
|
Environment=IDLE_TIMEOUT=300
|
||||||
WorkingDirectory=/home/george/projects/bibdle
|
Environment=PORT=5173
|
||||||
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
|
WorkingDirectory=/home/xenia/projects/bibdle
|
||||||
|
#ExecStart=/home/xenia/.nvm/versions/node/v24.13.0/bin/node build/index.js
|
||||||
|
ExecStart=/home/xenia/.bun/bin/bun --bun build/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[Socket]
|
|
||||||
ListenStream=5173
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
+385
@@ -0,0 +1,385 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="680"
|
||||||
|
viewBox="0 0 680 520"
|
||||||
|
version="1.1"
|
||||||
|
id="svg41"
|
||||||
|
sodipodi:docname="bibdle_logo.svg"
|
||||||
|
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview41"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="0.91550428"
|
||||||
|
inkscape:cx="321.68064"
|
||||||
|
inkscape:cy="144.18283"
|
||||||
|
inkscape:window-width="1512"
|
||||||
|
inkscape:window-height="921"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="33"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg41">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid41"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="1"
|
||||||
|
spacingy="1"
|
||||||
|
empcolor="#0099e5"
|
||||||
|
empopacity="0.30196078"
|
||||||
|
color="#0099e5"
|
||||||
|
opacity="0.14901961"
|
||||||
|
empspacing="5"
|
||||||
|
enabled="true"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs32">
|
||||||
|
<linearGradient
|
||||||
|
id="bgSq"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stop-color="rgb(110,154,202)"
|
||||||
|
id="stop1" />
|
||||||
|
<stop
|
||||||
|
offset="3.23%"
|
||||||
|
stop-color="rgb(111,155,203)"
|
||||||
|
id="stop2" />
|
||||||
|
<stop
|
||||||
|
offset="6.45%"
|
||||||
|
stop-color="rgb(112,156,203)"
|
||||||
|
id="stop3" />
|
||||||
|
<stop
|
||||||
|
offset="9.68%"
|
||||||
|
stop-color="rgb(114,158,204)"
|
||||||
|
id="stop4" />
|
||||||
|
<stop
|
||||||
|
offset="12.9%"
|
||||||
|
stop-color="rgb(115,159,205)"
|
||||||
|
id="stop5" />
|
||||||
|
<stop
|
||||||
|
offset="16.13%"
|
||||||
|
stop-color="rgb(116,160,205)"
|
||||||
|
id="stop6" />
|
||||||
|
<stop
|
||||||
|
offset="19.35%"
|
||||||
|
stop-color="rgb(118,162,206)"
|
||||||
|
id="stop7" />
|
||||||
|
<stop
|
||||||
|
offset="22.58%"
|
||||||
|
stop-color="rgb(119,163,207)"
|
||||||
|
id="stop8" />
|
||||||
|
<stop
|
||||||
|
offset="25.81%"
|
||||||
|
stop-color="rgb(121,165,208)"
|
||||||
|
id="stop9" />
|
||||||
|
<stop
|
||||||
|
offset="29.03%"
|
||||||
|
stop-color="rgb(123,167,209)"
|
||||||
|
id="stop10" />
|
||||||
|
<stop
|
||||||
|
offset="32.26%"
|
||||||
|
stop-color="rgb(125,168,210)"
|
||||||
|
id="stop11" />
|
||||||
|
<stop
|
||||||
|
offset="35.48%"
|
||||||
|
stop-color="rgb(127,170,211)"
|
||||||
|
id="stop12" />
|
||||||
|
<stop
|
||||||
|
offset="38.71%"
|
||||||
|
stop-color="rgb(130,172,212)"
|
||||||
|
id="stop13" />
|
||||||
|
<stop
|
||||||
|
offset="41.94%"
|
||||||
|
stop-color="rgb(132,175,213)"
|
||||||
|
id="stop14" />
|
||||||
|
<stop
|
||||||
|
offset="45.16%"
|
||||||
|
stop-color="rgb(135,177,214)"
|
||||||
|
id="stop15" />
|
||||||
|
<stop
|
||||||
|
offset="48.39%"
|
||||||
|
stop-color="rgb(138,180,215)"
|
||||||
|
id="stop16" />
|
||||||
|
<stop
|
||||||
|
offset="51.61%"
|
||||||
|
stop-color="rgb(141,182,216)"
|
||||||
|
id="stop17" />
|
||||||
|
<stop
|
||||||
|
offset="54.84%"
|
||||||
|
stop-color="rgb(145,185,218)"
|
||||||
|
id="stop18" />
|
||||||
|
<stop
|
||||||
|
offset="58.06%"
|
||||||
|
stop-color="rgb(149,188,219)"
|
||||||
|
id="stop19" />
|
||||||
|
<stop
|
||||||
|
offset="61.29%"
|
||||||
|
stop-color="rgb(153,191,220)"
|
||||||
|
id="stop20" />
|
||||||
|
<stop
|
||||||
|
offset="64.52%"
|
||||||
|
stop-color="rgb(158,195,222)"
|
||||||
|
id="stop21" />
|
||||||
|
<stop
|
||||||
|
offset="67.74%"
|
||||||
|
stop-color="rgb(163,198,223)"
|
||||||
|
id="stop22" />
|
||||||
|
<stop
|
||||||
|
offset="70.97%"
|
||||||
|
stop-color="rgb(169,202,224)"
|
||||||
|
id="stop23" />
|
||||||
|
<stop
|
||||||
|
offset="74.19%"
|
||||||
|
stop-color="rgb(174,206,226)"
|
||||||
|
id="stop24" />
|
||||||
|
<stop
|
||||||
|
offset="77.42%"
|
||||||
|
stop-color="rgb(181,209,227)"
|
||||||
|
id="stop25" />
|
||||||
|
<stop
|
||||||
|
offset="80.65%"
|
||||||
|
stop-color="rgb(188,213,228)"
|
||||||
|
id="stop26" />
|
||||||
|
<stop
|
||||||
|
offset="83.87%"
|
||||||
|
stop-color="rgb(195,217,229)"
|
||||||
|
id="stop27" />
|
||||||
|
<stop
|
||||||
|
offset="87.1%"
|
||||||
|
stop-color="rgb(203,221,230)"
|
||||||
|
id="stop28" />
|
||||||
|
<stop
|
||||||
|
offset="90.32%"
|
||||||
|
stop-color="rgb(210,225,230)"
|
||||||
|
id="stop29" />
|
||||||
|
<stop
|
||||||
|
offset="93.55%"
|
||||||
|
stop-color="rgb(218,228,229)"
|
||||||
|
id="stop30" />
|
||||||
|
<stop
|
||||||
|
offset="96.77%"
|
||||||
|
stop-color="rgb(224,230,227)"
|
||||||
|
id="stop31" />
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stop-color="rgb(227,228,223)"
|
||||||
|
id="stop32" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath
|
||||||
|
id="sqClip">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect32" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="ciClip">
|
||||||
|
<circle
|
||||||
|
cx="510"
|
||||||
|
cy="170"
|
||||||
|
r="130"
|
||||||
|
id="circle32" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="sqClip-9">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect32-8" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath1">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect1" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath2">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect2" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath3">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect3" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="sqClip-9-1">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect32-8-7" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath1-1">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect1-8" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath2-7">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect2-8" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath3-7">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect3-7" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<!-- Rounded square (favicon / app icon) -->
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
fill="url(#bgSq)"
|
||||||
|
id="rect33"
|
||||||
|
inkscape:export-filename="../Coding/bibdle/static/favicon.png"
|
||||||
|
inkscape:export-xdpi="23.63077"
|
||||||
|
inkscape:export-ydpi="23.63077" />
|
||||||
|
<!-- Circle (Discord server icon) -->
|
||||||
|
<circle
|
||||||
|
cx="510"
|
||||||
|
cy="170"
|
||||||
|
r="130"
|
||||||
|
fill="url(#bgSq)"
|
||||||
|
id="circle37"
|
||||||
|
inkscape:export-filename="../Coding/bibdle/static/bibdle-logo-circle.png"
|
||||||
|
inkscape:export-xdpi="378.09232"
|
||||||
|
inkscape:export-ydpi="378.09232" />
|
||||||
|
<rect
|
||||||
|
x="152"
|
||||||
|
y="78"
|
||||||
|
width="36"
|
||||||
|
height="184"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9)"
|
||||||
|
id="rect34-6"
|
||||||
|
transform="matrix(0.89748134,0,0,1,357.18847,2.5366858)" />
|
||||||
|
<rect
|
||||||
|
x="128"
|
||||||
|
y="88"
|
||||||
|
width="84"
|
||||||
|
height="26"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9)"
|
||||||
|
id="rect35-5"
|
||||||
|
transform="matrix(1,0,0,0.80796134,339.90604,30.104693)" />
|
||||||
|
<rect
|
||||||
|
x="96"
|
||||||
|
y="140"
|
||||||
|
width="148"
|
||||||
|
height="30"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9)"
|
||||||
|
id="rect36-7"
|
||||||
|
transform="matrix(1,0,0,0.82878095,339.90604,26.507353)" />
|
||||||
|
<rect
|
||||||
|
x="128"
|
||||||
|
y="210"
|
||||||
|
width="84"
|
||||||
|
height="20"
|
||||||
|
rx="4"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9)"
|
||||||
|
transform="rotate(15,330.33996,1512.3487)"
|
||||||
|
id="rect37-6" />
|
||||||
|
<rect
|
||||||
|
x="152"
|
||||||
|
y="78"
|
||||||
|
width="36"
|
||||||
|
height="184"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9-1)"
|
||||||
|
id="rect34-6-4"
|
||||||
|
transform="matrix(0.89748134,0,0,1,17.264653,0.15299483)" />
|
||||||
|
<rect
|
||||||
|
x="128"
|
||||||
|
y="88"
|
||||||
|
width="84"
|
||||||
|
height="26"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9-1)"
|
||||||
|
id="rect35-5-4"
|
||||||
|
transform="matrix(1,0,0,0.80796134,0.28036275,27.721002)" />
|
||||||
|
<rect
|
||||||
|
x="96"
|
||||||
|
y="140"
|
||||||
|
width="148"
|
||||||
|
height="30"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9-1)"
|
||||||
|
id="rect36-7-9"
|
||||||
|
transform="matrix(1,0,0,0.82878095,-0.01777671,24.123662)" />
|
||||||
|
<rect
|
||||||
|
x="128"
|
||||||
|
y="210"
|
||||||
|
width="84"
|
||||||
|
height="20"
|
||||||
|
rx="4"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9-1)"
|
||||||
|
transform="rotate(15,169.4311,220.168)"
|
||||||
|
id="rect37-6-1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.6 KiB |
@@ -3,16 +3,24 @@ set -e
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BUN=$(which bun)
|
||||||
|
|
||||||
echo "Pulling latest changes..."
|
echo "Pulling latest changes..."
|
||||||
git pull
|
PULL_OUTPUT=$(git pull)
|
||||||
|
echo "$PULL_OUTPUT"
|
||||||
|
if [ "$PULL_OUTPUT" = "Already up to date." ]; then
|
||||||
|
echo "Nothing to deploy."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
bun i
|
$BUN i
|
||||||
|
|
||||||
echo "Building..."
|
echo "Building..."
|
||||||
bun run build
|
$BUN --bun run build
|
||||||
|
|
||||||
echo "Restarting service..."
|
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||||
sudo systemctl restart bibdle
|
echo "Restarting service ($SERVICE_NAME)..."
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
if (!process.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/lib/server/db/schema.ts',
|
||||||
|
dialect: 'sqlite',
|
||||||
|
dbCredentials: { url: process.env.TEST_DATABASE_URL },
|
||||||
|
verbose: true,
|
||||||
|
strict: true
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `email` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint
|
||||||
|
CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `daily_completions` ADD `guesses` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `google_id` text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_google_id_unique` ON `user` (`google_id`);
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||||
|
"prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d",
|
||||||
|
"tables": {
|
||||||
|
"daily_completions": {
|
||||||
|
"name": "daily_completions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"anonymous_id": {
|
||||||
|
"name": "anonymous_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"guess_count": {
|
||||||
|
"name": "guess_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"anonymous_id_date_idx": {
|
||||||
|
"name": "anonymous_id_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_idx": {
|
||||||
|
"name": "date_idx",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_guess_idx": {
|
||||||
|
"name": "date_guess_idx",
|
||||||
|
"columns": [
|
||||||
|
"date",
|
||||||
|
"guess_count"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"daily_completions_anonymous_id_date_unique": {
|
||||||
|
"name": "daily_completions_anonymous_id_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"daily_verses": {
|
||||||
|
"name": "daily_verses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"book_id": {
|
||||||
|
"name": "book_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"verse_text": {
|
||||||
|
"name": "verse_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"name": "reference",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"daily_verses_date_unique": {
|
||||||
|
"name": "daily_verses_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_name": {
|
||||||
|
"name": "first_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_name": {
|
||||||
|
"name": "last_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"apple_id": {
|
||||||
|
"name": "apple_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_private": {
|
||||||
|
"name": "is_private",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_apple_id_unique": {
|
||||||
|
"name": "user_apple_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"apple_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "80883fb9-70cd-4fa5-b228-36358ffc4c40",
|
||||||
|
"prevId": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||||
|
"tables": {
|
||||||
|
"daily_completions": {
|
||||||
|
"name": "daily_completions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"anonymous_id": {
|
||||||
|
"name": "anonymous_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"guess_count": {
|
||||||
|
"name": "guess_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"guesses": {
|
||||||
|
"name": "guesses",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"anonymous_id_date_idx": {
|
||||||
|
"name": "anonymous_id_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_idx": {
|
||||||
|
"name": "date_idx",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_guess_idx": {
|
||||||
|
"name": "date_guess_idx",
|
||||||
|
"columns": [
|
||||||
|
"date",
|
||||||
|
"guess_count"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"daily_completions_anonymous_id_date_unique": {
|
||||||
|
"name": "daily_completions_anonymous_id_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"daily_verses": {
|
||||||
|
"name": "daily_verses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"book_id": {
|
||||||
|
"name": "book_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"verse_text": {
|
||||||
|
"name": "verse_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"name": "reference",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"daily_verses_date_unique": {
|
||||||
|
"name": "daily_verses_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_name": {
|
||||||
|
"name": "first_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_name": {
|
||||||
|
"name": "last_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"apple_id": {
|
||||||
|
"name": "apple_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_private": {
|
||||||
|
"name": "is_private",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_apple_id_unique": {
|
||||||
|
"name": "user_apple_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"apple_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,27 @@
|
|||||||
"when": 1765934144883,
|
"when": 1765934144883,
|
||||||
"tag": "0000_clumsy_impossible_man",
|
"tag": "0000_clumsy_impossible_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1770266674489,
|
||||||
|
"tag": "0001_loose_kree",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1770961427714,
|
||||||
|
"tag": "0002_outstanding_hiroim",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1774416309647,
|
||||||
|
"tag": "0003_overjoyed_mindworm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Export all daily verses to JSON
|
||||||
|
# Usage: ./export-verses.sh [path/to/database.db] [output.json]
|
||||||
|
|
||||||
|
DB="${1:-./local.db}"
|
||||||
|
OUT="${2:-verses.json}"
|
||||||
|
|
||||||
|
sqlite3 "$DB" <<'SQL' > "$OUT"
|
||||||
|
.mode json
|
||||||
|
SELECT
|
||||||
|
CASE book_id
|
||||||
|
WHEN 'GEN' THEN 'Genesis'
|
||||||
|
WHEN 'EXO' THEN 'Exodus'
|
||||||
|
WHEN 'LEV' THEN 'Leviticus'
|
||||||
|
WHEN 'NUM' THEN 'Numbers'
|
||||||
|
WHEN 'DEU' THEN 'Deuteronomy'
|
||||||
|
WHEN 'JOS' THEN 'Joshua'
|
||||||
|
WHEN 'JDG' THEN 'Judges'
|
||||||
|
WHEN 'RUT' THEN 'Ruth'
|
||||||
|
WHEN '1SA' THEN '1 Samuel'
|
||||||
|
WHEN '2SA' THEN '2 Samuel'
|
||||||
|
WHEN '1KI' THEN '1 Kings'
|
||||||
|
WHEN '2KI' THEN '2 Kings'
|
||||||
|
WHEN '1CH' THEN '1 Chronicles'
|
||||||
|
WHEN '2CH' THEN '2 Chronicles'
|
||||||
|
WHEN 'EZR' THEN 'Ezra'
|
||||||
|
WHEN 'NEH' THEN 'Nehemiah'
|
||||||
|
WHEN 'EST' THEN 'Esther'
|
||||||
|
WHEN 'JOB' THEN 'Job'
|
||||||
|
WHEN 'PSA' THEN 'Psalms'
|
||||||
|
WHEN 'PRO' THEN 'Proverbs'
|
||||||
|
WHEN 'ECC' THEN 'Ecclesiastes'
|
||||||
|
WHEN 'SNG' THEN 'Song of Solomon'
|
||||||
|
WHEN 'ISA' THEN 'Isaiah'
|
||||||
|
WHEN 'JER' THEN 'Jeremiah'
|
||||||
|
WHEN 'LAM' THEN 'Lamentations'
|
||||||
|
WHEN 'EZK' THEN 'Ezekiel'
|
||||||
|
WHEN 'DAN' THEN 'Daniel'
|
||||||
|
WHEN 'HOS' THEN 'Hosea'
|
||||||
|
WHEN 'JOL' THEN 'Joel'
|
||||||
|
WHEN 'AMO' THEN 'Amos'
|
||||||
|
WHEN 'OBA' THEN 'Obadiah'
|
||||||
|
WHEN 'JON' THEN 'Jonah'
|
||||||
|
WHEN 'MIC' THEN 'Micah'
|
||||||
|
WHEN 'NAM' THEN 'Nahum'
|
||||||
|
WHEN 'HAB' THEN 'Habakkuk'
|
||||||
|
WHEN 'ZEP' THEN 'Zephaniah'
|
||||||
|
WHEN 'HAG' THEN 'Haggai'
|
||||||
|
WHEN 'ZEC' THEN 'Zechariah'
|
||||||
|
WHEN 'MAL' THEN 'Malachi'
|
||||||
|
WHEN 'MAT' THEN 'Matthew'
|
||||||
|
WHEN 'MRK' THEN 'Mark'
|
||||||
|
WHEN 'LUK' THEN 'Luke'
|
||||||
|
WHEN 'JHN' THEN 'John'
|
||||||
|
WHEN 'ACT' THEN 'Acts'
|
||||||
|
WHEN 'ROM' THEN 'Romans'
|
||||||
|
WHEN '1CO' THEN '1 Corinthians'
|
||||||
|
WHEN '2CO' THEN '2 Corinthians'
|
||||||
|
WHEN 'GAL' THEN 'Galatians'
|
||||||
|
WHEN 'EPH' THEN 'Ephesians'
|
||||||
|
WHEN 'PHP' THEN 'Philippians'
|
||||||
|
WHEN 'COL' THEN 'Colossians'
|
||||||
|
WHEN '1TH' THEN '1 Thessalonians'
|
||||||
|
WHEN '2TH' THEN '2 Thessalonians'
|
||||||
|
WHEN '1TI' THEN '1 Timothy'
|
||||||
|
WHEN '2TI' THEN '2 Timothy'
|
||||||
|
WHEN 'TIT' THEN 'Titus'
|
||||||
|
WHEN 'PHM' THEN 'Philemon'
|
||||||
|
WHEN 'HEB' THEN 'Hebrews'
|
||||||
|
WHEN 'JAS' THEN 'James'
|
||||||
|
WHEN '1PE' THEN '1 Peter'
|
||||||
|
WHEN '2PE' THEN '2 Peter'
|
||||||
|
WHEN '1JN' THEN '1 John'
|
||||||
|
WHEN '2JN' THEN '2 John'
|
||||||
|
WHEN '3JN' THEN '3 John'
|
||||||
|
WHEN 'JUD' THEN 'Jude'
|
||||||
|
WHEN 'REV' THEN 'Revelation'
|
||||||
|
ELSE book_id
|
||||||
|
END AS book,
|
||||||
|
verse_text AS verse,
|
||||||
|
reference AS citation,
|
||||||
|
date
|
||||||
|
FROM daily_verses
|
||||||
|
ORDER BY date;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Exported to $OUT"
|
||||||
+7
-6
@@ -1,29 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "bibdle",
|
"name": "bibdle",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.5.0",
|
"version": "3.0.0alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "bun --bun vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test": "bun test",
|
||||||
|
"test:watch": "bun test --watch",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
|
||||||
"@oslojs/encoding": "^1.1.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/bun": "^1.3.8",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -35,8 +35,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.6.2",
|
"drizzle": "^1.4.0",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Community Verse Submissions — Implementation Plan
|
||||||
|
|
||||||
|
Source of truth: `spec.md`. Each step is independently verifiable and builds on the previous.
|
||||||
|
|
||||||
|
## Step 1 — Schema & migration
|
||||||
|
- Add `verse_submissions` table to `src/lib/server/db/schema.ts`:
|
||||||
|
- `id` text pk (`Bun.randomUUIDv7()`)
|
||||||
|
- `user_id` text not null, FK → `user.id` **ON DELETE SET NULL** (nullable column to allow that)
|
||||||
|
- `scheduled_date` text not null **unique**
|
||||||
|
- `selected_book_id` text not null
|
||||||
|
- `selected_chapter` integer not null
|
||||||
|
- `selected_verse` integer not null
|
||||||
|
- `submitted_at` integer not null (server UTC **millis**; raw integer, drives 7-day cooldown)
|
||||||
|
- indexes: `user_id`, unique on `scheduled_date` (the column-level unique handles this)
|
||||||
|
- `bun run db:push` to dev.db.
|
||||||
|
- Verify: `sqlite3 dev.db ".schema verse_submissions"`.
|
||||||
|
|
||||||
|
> Note: spec says `user_id` FK should be SET NULL on account deletion, so the column is **nullable** despite "not null" in the table sketch (the edge-cases section overrides: "FK should be ON DELETE SET NULL"). Resolution: make `user_id` nullable + ON DELETE SET NULL. The admin LEFT JOIN renders "(deleted user)" when null. This is the spec-intended behavior.
|
||||||
|
|
||||||
|
## Step 2 — Bible structure + windowing helpers
|
||||||
|
- Export `getChapterCount`, `getVerseCount` from `xml-bible.ts` (currently private).
|
||||||
|
- Add `composeVerseWindow(bookNumber, chapter, verse)`:
|
||||||
|
- Computes the selected verse's index `v` within the book (contiguous 1-based across chapters), `bookStart`=1, `bookEnd`=total verses in book.
|
||||||
|
- Default window `[v-2, v-1, v]`; if `v-2 < 1` fall forward to `[v, v+1, v+2]`.
|
||||||
|
- Returns `{ bookId, bookName, verses: string[], startChapter, startVerse, endChapter, endVerse, selectedVerse }` by calling `extractVerses` per chapter and concatenating for cross-chapter windows.
|
||||||
|
- Add `formatWindowReference(bookName, startChapter, startVerse, endChapter, endVerse)`: same-chapter → `Book C:S-E`; cross-chapter → `Book C1:S1–C2:E2`.
|
||||||
|
- Unit tests in `tests/verse-window.test.ts`.
|
||||||
|
|
||||||
|
## Step 3 — Public APIs
|
||||||
|
- `GET /api/bible/structure` → `[{ bookId, chapters: number[] }, ...]` for all 66 books (verse counts per chapter). Cached in-memory.
|
||||||
|
- `GET /api/verse-window?bookId=gen&chapter=1&verse=1` → `{ windowVerses, reference, bookId, selectedVerse }` via `composeVerseWindow`.
|
||||||
|
- Verify with curl.
|
||||||
|
|
||||||
|
## Step 4 — Admin module + `/scheduled-verses` page
|
||||||
|
- `src/lib/server/admin.ts` exporting `ADMIN_EMAIL = 'geohpowell@gmail.com'`.
|
||||||
|
- `src/routes/scheduled-verses/+page.server.ts`: require `locals.user?.email === ADMIN_EMAIL` else auth redirect / 403 access-denied page.
|
||||||
|
- `+page.svelte`: table joining `verse_submissions` LEFT JOIN `daily_verses` (on `scheduled_date = date`) LEFT JOIN `user` (on `user_id`), sorted by `scheduled_date` asc. Columns per spec. "(deleted user)" when user null.
|
||||||
|
- Not linked from nav. Verify by direct URL.
|
||||||
|
|
||||||
|
## Step 5 — Submit backend + lazy gap-fill
|
||||||
|
- `POST /api/submit-verse` (auth required): body `{ bookId, chapter, verse, localDate }`.
|
||||||
|
1. Require `locals.user` (401).
|
||||||
|
2. Solved-today gate: `dailyCompletions` row `(anonymousId=user.id, date=localDate)` else 403.
|
||||||
|
3. Cooldown: most recent `verse_submissions.submitted_at` for user; if `< 7d` ago → 429 `{ cooldownEndsAt }`.
|
||||||
|
4. Structural validation: bookId valid, chapter in `[1, getChapterCount]`, verse in `[1, getVerseCount]`.
|
||||||
|
5. Compute window via `composeVerseWindow`.
|
||||||
|
6. Scheduling scan: fetch all `daily_verses` with `date > today` (server UTC) ordered; walk day-by-day from tomorrow; validity = empty + no back-to-back with D-1/D+2 committed neighbors + no same-window-ref row within ±60 days (bounded lookup). Find earliest valid `D`.
|
||||||
|
7. Transaction: insert `daily_verses` `{ id, date: D, bookId, reference, verseText, createdAt: now }` + `verse_submissions` `{ id, user_id, scheduled_date: D, selected_book_id, selected_chapter, selected_verse, submitted_at: now }`. On `SQLITE_CONSTRAINT_UNIQUE` retry scan from `D+1`, up to 5 attempts.
|
||||||
|
8. Return `{ scheduledDate: D, reference, windowText }`. Emit rybbit event.
|
||||||
|
- `GET /api/submit-verse/status?localDate=YYYY-MM-DD` (auth required): `{ canSubmit, cooldownEndsAt, lastSubmission, upcoming }`.
|
||||||
|
- Modify `getVerseForDate` lazy path: re-roll `getRandomVerses()` until book differs from committed D-1 and D+1 neighbors (cap ~20 retries).
|
||||||
|
- Verify with curl end-to-end.
|
||||||
|
|
||||||
|
## Step 6 — Frontend `SubmitVerse.svelte`
|
||||||
|
- New component rendered in `WinScreen.svelte` near the progress button, sharing `.progress-btn` styling.
|
||||||
|
- States: logged-out (Apple/Google dropdown mirroring progress button), cooldown (greyed + `CountdownTimer`-style countdown to `cooldownEndsAt`), can-submit (cascading Book→Chapter→Verse `<select>`s from `/api/bible/structure`, debounced 250ms preview via `/api/verse-window`, Submit button, success confirmation with scheduled date, inline 429/403 errors).
|
||||||
|
- Pass `isLoggedIn` + `anonymousId` through (already on WinScreen props).
|
||||||
|
- Verify in browser.
|
||||||
|
|
||||||
|
## Step 7 — Tests
|
||||||
|
- `tests/verse-window.test.ts`: book-start fall-forward (Gen 1:1 → [1,2,3]), cross-chapter window, same-chapter window, end-of-book safety.
|
||||||
|
- `tests/scheduling.test.ts` (or unit-test the pure scan function against an in-memory fixture): no-back-to-back, 60-day repeat skip, earliest-valid-date selection.
|
||||||
|
- Cooldown arithmetic test.
|
||||||
|
|
||||||
|
## Step 8 — Docs
|
||||||
|
- Update `README.md` schema table (add `verse_submissions`) and routes/API tables (new endpoints + `/scheduled-verses`).
|
||||||
|
- Note `ADMIN_EMAIL` constant location.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (!dbUrl) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
|
const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice(5) : dbUrl;
|
||||||
|
const db = new Database(path.resolve(dbPath));
|
||||||
|
|
||||||
|
const duplicates = db.query(`
|
||||||
|
SELECT anonymous_id, date, COUNT(*) as count
|
||||||
|
FROM daily_completions
|
||||||
|
GROUP BY anonymous_id, date
|
||||||
|
HAVING count > 1
|
||||||
|
`).all() as { anonymous_id: string; date: string; count: number }[];
|
||||||
|
|
||||||
|
if (duplicates.length === 0) {
|
||||||
|
console.log('No duplicates found.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${duplicates.length} duplicate group(s):`);
|
||||||
|
|
||||||
|
const deleteStmt = db.query(`
|
||||||
|
DELETE FROM daily_completions
|
||||||
|
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id FROM daily_completions
|
||||||
|
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||||
|
ORDER BY completed_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const { anonymous_id, date, count } of duplicates) {
|
||||||
|
deleteStmt.run({ $anonymous_id: anonymous_id, $date: date });
|
||||||
|
console.log(` ${anonymous_id} / ${date}: kept earliest, deleted ${count - 1} row(s) (had ${count})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
db.close();
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Database from 'bun:sqlite';
|
||||||
|
|
||||||
|
// Database path - adjust if your database is located elsewhere
|
||||||
|
const dbPath = Bun.env.DATABASE_URL || './local.db';
|
||||||
|
console.log(`Connecting to database: ${dbPath}`);
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
interface DuplicateGroup {
|
||||||
|
anonymous_id: string;
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Completion {
|
||||||
|
id: string;
|
||||||
|
anonymous_id: string;
|
||||||
|
date: string;
|
||||||
|
guess_count: number;
|
||||||
|
completed_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Finding duplicates...\n');
|
||||||
|
|
||||||
|
// Find all (anonymous_id, date) pairs with duplicates
|
||||||
|
const duplicatesQuery = db.query<DuplicateGroup, []>(`
|
||||||
|
SELECT anonymous_id, date, COUNT(*) as count
|
||||||
|
FROM daily_completions
|
||||||
|
GROUP BY anonymous_id, date
|
||||||
|
HAVING count > 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const duplicates = duplicatesQuery.all();
|
||||||
|
console.log(`Found ${duplicates.length} duplicate groups\n`);
|
||||||
|
|
||||||
|
if (duplicates.length === 0) {
|
||||||
|
console.log('No duplicates to clean up!');
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDeleted = 0;
|
||||||
|
|
||||||
|
// Process each duplicate group
|
||||||
|
for (const dup of duplicates) {
|
||||||
|
// Get all completions for this (anonymous_id, date) pair
|
||||||
|
const completionsQuery = db.query<Completion, [string, string]>(`
|
||||||
|
SELECT id, anonymous_id, date, guess_count, completed_at
|
||||||
|
FROM daily_completions
|
||||||
|
WHERE anonymous_id = ? AND date = ?
|
||||||
|
ORDER BY completed_at ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const completions = completionsQuery.all(dup.anonymous_id, dup.date);
|
||||||
|
console.log(` ${dup.anonymous_id} on ${dup.date}: ${completions.length} entries`);
|
||||||
|
|
||||||
|
// Keep the first (earliest completion), delete the rest
|
||||||
|
const toKeep = completions[0];
|
||||||
|
const toDelete = completions.slice(1);
|
||||||
|
|
||||||
|
console.log(` Keeping: ${toKeep.id} (completed at ${new Date(toKeep.completed_at * 1000).toISOString()})`);
|
||||||
|
|
||||||
|
const deleteQuery = db.query('DELETE FROM daily_completions WHERE id = ?');
|
||||||
|
|
||||||
|
for (const comp of toDelete) {
|
||||||
|
console.log(` Deleting: ${comp.id} (completed at ${new Date(comp.completed_at * 1000).toISOString()})`);
|
||||||
|
deleteQuery.run(comp.id);
|
||||||
|
totalDeleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Deduplication complete!`);
|
||||||
|
console.log(`Total records deleted: ${totalDeleted}`);
|
||||||
|
console.log(`Unique completions preserved: ${duplicates.length}`);
|
||||||
|
|
||||||
|
db.close();
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
# Seed the database with 10 fake completions with random anonymous_ids
|
||||||
|
# Useful for testing streak percentile and stats features
|
||||||
|
|
||||||
|
DB_PATH="dev.db"
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
NOW=$(date +%s)
|
||||||
|
|
||||||
|
echo "Seeding 10 fake completions for date: $TODAY"
|
||||||
|
|
||||||
|
for i in {1..50}; do
|
||||||
|
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
ANON_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
GUESS_COUNT=$(( (RANDOM % 6) + 1 )) # 1–6 guesses
|
||||||
|
|
||||||
|
sqlite3 "$DB_PATH" "
|
||||||
|
INSERT OR IGNORE INTO daily_completions (id, anonymous_id, date, guess_count, completed_at)
|
||||||
|
VALUES ('$ID', '$ANON_ID', '$TODAY', $GUESS_COUNT, $NOW);
|
||||||
|
"
|
||||||
|
|
||||||
|
echo " [$i] anon=$ANON_ID guesses=$GUESS_COUNT"
|
||||||
|
done
|
||||||
|
|
||||||
|
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '$TODAY';")
|
||||||
|
echo "✓ Done. Total completions for $TODAY: $TOTAL"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||||
|
import { generateShareText } from '../src/lib/utils/share';
|
||||||
|
import { bibleBooks } from '../src/lib/types/bible';
|
||||||
|
|
||||||
|
const NUM_VERSES = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < NUM_VERSES; i++) {
|
||||||
|
const verse = await fetchRandomVerse();
|
||||||
|
|
||||||
|
// Build a fake "solved in N guesses" scenario with some wrong guesses first
|
||||||
|
const correctBook = bibleBooks.find((b) => b.id === verse.bookId)!;
|
||||||
|
const wrongBook = bibleBooks.find((b) => b.id !== verse.bookId)!;
|
||||||
|
const guessCount = Math.floor(Math.random() * 5) + 1;
|
||||||
|
const guesses = [
|
||||||
|
...Array(guessCount - 1).fill(null).map(() => ({
|
||||||
|
book: wrongBook,
|
||||||
|
testamentMatch: wrongBook.testament === correctBook.testament,
|
||||||
|
sectionMatch: wrongBook.section === correctBook.section,
|
||||||
|
adjacent: Math.abs(wrongBook.order - correctBook.order) === 1,
|
||||||
|
})),
|
||||||
|
{ book: correctBook, testamentMatch: true, sectionMatch: true, adjacent: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fakeStreak = Math.random() > 0.5 ? Math.floor(Math.random() * 14) + 2 : 0;
|
||||||
|
|
||||||
|
const shareText = generateShareText({
|
||||||
|
guesses,
|
||||||
|
correctBookId: verse.bookId,
|
||||||
|
dailyVerseDate: new Date().toISOString().slice(0, 10),
|
||||||
|
chapterCorrect: guessCount === 1 && Math.random() > 0.5,
|
||||||
|
isLoggedIn: Math.random() > 0.5,
|
||||||
|
streak: fakeStreak > 0 ? fakeStreak : undefined,
|
||||||
|
origin: 'https://bibdle.com',
|
||||||
|
verseText: verse.verseText,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n── Verse ${i + 1}: ${verse.reference} ──`);
|
||||||
|
console.log(`RAW: ${verse.verseText}`);
|
||||||
|
console.log('─'.repeat(40));
|
||||||
|
console.log(shareText);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||||
|
import { getVerseSnippet } from '../src/lib/utils/share';
|
||||||
|
|
||||||
|
const NUM_VERSES = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < NUM_VERSES; i++) {
|
||||||
|
const verse = await fetchRandomVerse();
|
||||||
|
|
||||||
|
console.log(getVerseSnippet(verse.verseText));
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# Spec: Community Verse Submissions
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [x] **Step 1 — Schema & migration:** add `verse_submissions` table to `src/lib/server/db/schema.ts` (nullable `user_id` FK → `user.id` ON DELETE SET NULL, unique `scheduled_date`, index on `user_id`), pushed to dev.db.
|
||||||
|
- [x] **Step 2 — Bible structure + windowing helpers:** export `getChapterCount`/`getVerseCount` from `xml-bible.ts`; add `composeVerseWindow()` (fall-forward 3-verse window, never crosses book, crosses chapter within book) and `formatWindowReference()` (hyphen same-chapter, en-dash cross-chapter). Tests in `tests/verse-window.test.ts` (17 pass).
|
||||||
|
- [x] **Step 3 — Public APIs:** `GET /api/bible/structure` (66-book verse counts for cascading dropdowns) and `GET /api/verse-window` (live 3-verse preview).
|
||||||
|
- [x] **Step 4 — Admin module + `/scheduled-verses` page:** `src/lib/server/admin.ts` (`ADMIN_EMAIL`), auth-walled admin view joining `verse_submissions` → `daily_verses` → `user`.
|
||||||
|
- [x] **Step 5 — Submit backend + lazy gap-fill:** `POST /api/submit-verse` (solved-today gate, 7-day cooldown, structural validation, scheduling scan with empty/no-back-to-back/60-day-repeat rules, transaction + retry), `GET /api/submit-verse/status`, modified `getVerseForDate` neighbor avoidance.
|
||||||
|
- [x] **Step 6 — Frontend `SubmitVerse.svelte`:** three states (logged-out sign-in dropdown, cooldown + countdown, cascading selects + preview + submit), wired into `WinScreen.svelte`.
|
||||||
|
- [x] **Step 7 — Tests:** scheduling validity, cooldown arithmetic, concurrency.
|
||||||
|
- [x] **Step 8 — Docs:** update README schema + routes/API tables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Let authenticated users submit a Bible verse they love. Submissions are scheduled as future "verses of the day," mixed so that **no two consecutive calendar days ever feature the same book**, and spaced so an exact 3-verse window never repeats within 60 days. Selection is dropdown-based (cascading `<select>`s) so the system can compose verses purely from existing Bible-lookup functions already in the codebase — no free-text entry, so typos are impossible and content is always canonical NKJV text.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- A new button on the **win screen** (`WinScreen.svelte`), visually consistent with the existing "📈 See your progress" neobrutalist button.
|
||||||
|
- Logged-out users who click it get a dropdown prompting sign-in (mirroring the existing progress button's Apple/Google dropdown).
|
||||||
|
- Logged-in users get a cascading Book → Chapter → Verse selector with a live 3-verse preview.
|
||||||
|
- Each submission immediately reserves a concrete future date; the submitter is shown that date on success.
|
||||||
|
- A 7-day rolling cooldown (server UTC) gates submissions, with the button greyed out and a countdown timer shown while active.
|
||||||
|
- Attribution is **anonymous everywhere** — submissions are never credited to a user publicly.
|
||||||
|
|
||||||
|
## Non-goals (explicitly decided)
|
||||||
|
|
||||||
|
- **No giveaway-text filtering.** Verses that mention their own book/author (e.g. Eph 1:1, Isa 1:1) are allowed. The random generator already permits these.
|
||||||
|
- **No persistent pending queue / no cron.** Assignment happens at submit time, not via a background job.
|
||||||
|
- **No withdrawal / deletion / editing.** Submissions are final (references come from dropdowns, so typos are impossible).
|
||||||
|
- **No guardrail on how far out a verse is scheduled.** If the calendar is dense, a submission may land months or years in the future; this is accepted.
|
||||||
|
- **No per-user pending cap, no global cap.** The 7-day rolling cooldown is the only rate limit.
|
||||||
|
- **No attribution / leaderboard / share-text changes.** Submitter identity is recorded only for rate-limiting and the solved-today gate; it is never displayed.
|
||||||
|
- **No backfill of gap days.** Sparse future `dailyVerses` rows are fine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New table: `verse_submissions`
|
||||||
|
|
||||||
|
A log of who submitted what and when, plus the assigned scheduled date. The canonical verse text/reference lives in `daily_verses` (keyed by `date`); this table links a user to a scheduled date.
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | text (pk) | `Bun.randomUUIDv7()` |
|
||||||
|
| `user_id` | text, not null, FK → `user.id` | the submitter (never displayed) |
|
||||||
|
| `scheduled_date` | text, not null, **unique** | `YYYY-MM-DD` of the reserved `daily_verses` row |
|
||||||
|
| `selected_book_id` | text, not null | the book the user chose |
|
||||||
|
| `selected_chapter` | integer, not null | the chapter the user chose |
|
||||||
|
| `selected_verse` | integer, not null | the single verse the user chose (the "anchor") |
|
||||||
|
| `submitted_at` | integer (timestamp), not null | server UTC millis — drives the 7-day cooldown |
|
||||||
|
|
||||||
|
Indexes: `user_id` (cooldown lookup + "your upcoming verses"), `scheduled_date` (unique — one submission per scheduled date).
|
||||||
|
|
||||||
|
No `status`, `withdrawn_at`, or attribution columns. Denormalized window/verseText/reference are **not** stored here; they live on `daily_verses` and are joined by `scheduled_date`.
|
||||||
|
|
||||||
|
### `daily_verses` (unchanged schema)
|
||||||
|
|
||||||
|
Submissions simply write a new row with a future `date`, exactly as the existing lazy path does for the current day. `getVerseForDate(date)` already returns a pre-existing row if present, so a pre-written submission row for a future date is served unchanged when its day arrives.
|
||||||
|
|
||||||
|
The new behavior:
|
||||||
|
- **Submission rows** are written at submit time for a future date (the date returned by the scheduling scan).
|
||||||
|
- **Gap days** (dates with no committed row when a player arrives) are filled lazily by the existing random path — extended to respect the no-back-to-back rule (see *Lazy gap-fill* below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verse Windowing
|
||||||
|
|
||||||
|
A submission selects **one** verse (book → chapter → verse). The displayed daily verse is a **3-verse window** anchored on that verse, for difficulty consistency with the existing game (`getRandomVerses` defaults to 3 consecutive verses).
|
||||||
|
|
||||||
|
### Window algorithm (deterministic)
|
||||||
|
|
||||||
|
Let `v` be the selected verse's index within its **book** (a contiguous 1-based index across all chapters of that book). Let `bookStart` = 1 (first verse index in the book) and `bookEnd` = last verse index in the book.
|
||||||
|
|
||||||
|
- Default window: `[v-2, v-1, v]` (the two preceding in-book verses + the selected verse).
|
||||||
|
- If `v - 2 < bookStart` (not enough preceding verses in-book — only happens at a book's very start, e.g. Gen 1:1, John 1:1): **fall forward** so the window is 3 consecutive in-book verses that still includes `v`, i.e. start = `v`, window = `[v, v+1, v+2]`. (Every Bible book has ≥3 verses after its opening, so this always succeeds.)
|
||||||
|
- The window **never crosses a book boundary**. Crossing a chapter boundary **within the same book** is allowed and expected (e.g. a window spanning the end of chapter 1 and the start of chapter 2).
|
||||||
|
|
||||||
|
This reuses existing functions: `getChapterCount`, `getVerseCount`, `extractVerses` (called per chapter; for cross-chapter windows, call `extractVerses` twice and concatenate), `getBookByNumber`/`getBookById`, and `formatReference` (extended to format cross-chapter ranges, e.g. `Genesis 1:31–2:1`; same-chapter stays `Genesis 1:1-3`).
|
||||||
|
|
||||||
|
The selected verse's **book** is the day's `bookId` (the guess target). All three displayed verses come from that same book, so the guessing game stays fair.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduling Algorithm (assign-at-submit)
|
||||||
|
|
||||||
|
When a submission is accepted, the server scans forward from **tomorrow** (server UTC date) to find the earliest valid candidate date `D`, then writes the `daily_verses` row for `D` immediately and records a `verse_submissions` row. The submitter is shown `D`.
|
||||||
|
|
||||||
|
### Validity checks for candidate date `D` and submission book `B`
|
||||||
|
|
||||||
|
`D` is valid iff **all** hold:
|
||||||
|
|
||||||
|
1. **Empty:** no `daily_verses` row exists at `D`.
|
||||||
|
2. **No back-to-back with prior committed neighbor:** the `daily_verses` row at `D-1` (if one exists) must have `book_id != B`.
|
||||||
|
3. **No back-to-back with next committed neighbor:** the `daily_verses` row at `D+1` (if one exists) must have `book_id != B`.
|
||||||
|
4. **60-day repeat distance:** no `daily_verses` row with the **same 3-verse window** exists whose date is within `[D-60, D+60]` days inclusive. The window identity is its canonical reference string (`book_id` + formatted `chapter:startverse-endverse`, cross-chapter-aware). This is a *soft* repeat rule: repeats are allowed, just not within ±60 days of any prior/scheduled appearance.
|
||||||
|
|
||||||
|
### Implementation note (performance)
|
||||||
|
|
||||||
|
Don't query per candidate date. Instead, fetch all `daily_verses` rows with `date > today` ordered by date into memory, then walk day-by-day from tomorrow, checking the four rules against the in-memory set + a single range query for the 60-day repeat check (windowed by reference). Historical rows (date ≤ today) only matter for rule 4; a bounded lookup (rows with the same window-ref within 60 days of `D`) suffices.
|
||||||
|
|
||||||
|
### Concurrency
|
||||||
|
|
||||||
|
The scan-then-insert is a critical section. Two concurrent submissions could both select the same `D`. Wrap insert in a transaction; on a `SQLITE_CONSTRAINT_UNIQUE` failure on `daily_verses.date` (or `verse_submissions.scheduled_date`), re-run the scan from `D+1`. Retry up to a small bound (e.g. 5). SQLite under `bun:sqlite` handles this with a transaction.
|
||||||
|
|
||||||
|
### Why this preserves no-back-to-back globally
|
||||||
|
|
||||||
|
Adjacency is only ever checked against **committed** rows (D-1, D+1). Gap days (empty between two committed rows) are filled lazily later by a random verse whose generator also respects committed neighbors (see below). So at play-time, every adjacent pair of days has been checked at write-time. The invariant is maintained inductively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lazy Gap-Fill (modified `getVerseForDate`)
|
||||||
|
|
||||||
|
When a player opens a date with no committed `daily_verses` row, the existing random path runs — **extended** so the random verse's book differs from any committed neighbor:
|
||||||
|
|
||||||
|
- If `D-1` has a committed row with book `X`, the random book must be `!= X`.
|
||||||
|
- If `D+1` has a committed row with book `Y`, the random book must be `!= Y`.
|
||||||
|
- Re-try `getRandomVerses()` until both hold (66 books, ≤2 excluded → ~97% success per try; cap at ~20 retries, then accept). `getRandomVerses` already returns 3 consecutive in-book verses; no windowing change needed for random fillers.
|
||||||
|
|
||||||
|
Submission days are pre-written, so they skip this path entirely (`getVerseForDate` returns the existing row).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting & Cooldown
|
||||||
|
|
||||||
|
- **One submission per user per rolling 7×24h window**, measured in **server UTC** (not gameable; smooth countdown).
|
||||||
|
- `cooldownEndsAt = lastSubmission.submitted_at + 7 days` (server UTC millis).
|
||||||
|
- The win-screen button is **always visible**; when the cooldown is active it is **greyed out and disabled**, with a small countdown timer below it (target `cooldownEndsAt`).
|
||||||
|
- Query: select the user's most recent `verse_submissions.submitted_at`; if `now - submitted_at < 7d`, cooldown is active.
|
||||||
|
|
||||||
|
### Solved-today gate (API-level)
|
||||||
|
|
||||||
|
The `POST /api/submit-verse` endpoint requires that the user has a `daily_completions` row for **their local today**. The client sends `localDate` (the same `YYYY-MM-DD` it already computes via `toLocaleDateString("en-CA")` and sends to other endpoints). The server checks `dailyCompletions` for a row with `anonymous_id = user.id` (for logged-in users, `anonymousId` is the user id after migration) and `date = localDate`. If absent → `403` with `{ error: "Solve today's puzzle first" }`.
|
||||||
|
|
||||||
|
This is an **engagement gate**, not a security necessity (submissions are anonymous → low spam incentive), but it ties the feature to real play and blocks scripted submissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation (no content moderation)
|
||||||
|
|
||||||
|
Because verses are chosen from canonical NKJV via dropdowns, no profanity/inappropriate-content filter is needed. Pre-submit validation is purely structural:
|
||||||
|
|
||||||
|
- `bookId` is one of the 66 known books (`getBookById`).
|
||||||
|
- `chapter` is in `[1, getChapterCount(bookNumber)]`.
|
||||||
|
- `verse` is in `[1, getVerseCount(bookNumber, chapter)]`.
|
||||||
|
- Cooldown not active.
|
||||||
|
- Solved-today gate passed.
|
||||||
|
- (The 60-day repeat and no-back-to-back rules are enforced by the scheduling scan, not by pre-rejecting the user's selection. A user can pick any valid verse; the scan will simply place it on the earliest valid date, possibly far out.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `/scheduled-verses` (auth-walled, admin-only)
|
||||||
|
|
||||||
|
A private admin page gated to the single account `geohpowell@gmail.com`. This is the **one place** where submitter identity is surfaced — attribution is anonymous everywhere else (public game, share text, etc.), but this admin view is the exception.
|
||||||
|
|
||||||
|
**Access control:**
|
||||||
|
- `+page.server.ts` `load` requires `locals.user` and `locals.user.email === 'geohpowell@gmail.com'`.
|
||||||
|
- Not authenticated → redirect to the sign-in modal/flow (same pattern as `/progress`, `/stats`).
|
||||||
|
- Authenticated but wrong account → `403` / a plain "not authorized" page. Do not leak the existence of privileged data; the page simply renders an access-denied state.
|
||||||
|
- The email check is a literal string match; if the admin email ever changes, update the constant (single source in one server module, e.g. `src/lib/server/admin.ts` exporting `ADMIN_EMAIL`).
|
||||||
|
- No `isPrivate` bypass needed — the admin is trusted to see all submitters regardless of their `isPrivate` flag.
|
||||||
|
|
||||||
|
**Page contents:** a table of all `verse_submissions` rows joined to `daily_verses` (by `scheduled_date = daily_verses.date`) and `user` (by `user_id`), one row per submission:
|
||||||
|
|
||||||
|
| Column | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| Scheduled date | `verse_submissions.scheduled_date` (rendered human-readable) |
|
||||||
|
| Reference | `daily_verses.reference` |
|
||||||
|
| Window text | `daily_verses.verse_text` (the 3-verse window) |
|
||||||
|
| Book | `daily_verses.book_id` (resolve to name via `getBookById`) |
|
||||||
|
| Submitted by | `user.email` (and `firstName lastName` if present) |
|
||||||
|
| Submitted at | `verse_submissions.submitted_at` (server UTC, rendered) |
|
||||||
|
| Selected verse | `verse_submissions.selected_book_id` / `selected_chapter` / `selected_verse` (the anchor the user picked) |
|
||||||
|
|
||||||
|
Sorted by `scheduled_date` ascending (so the upcoming calendar reads top-to-bottom). Include past rows (already-played submissions) — the table is a full historical + future log, not just pending. A small filter toggle (All / Upcoming / Past) is a nice-to-have, not required.
|
||||||
|
|
||||||
|
Since attribution appears here, `verse_submissions.user_id` must resolve even for deleted accounts — use a `LEFT JOIN` to `user` and render "(deleted user)" when the join is null. (Matches the `ON DELETE SET NULL` decision in the edge-cases section: the scheduled verse still plays out; the admin just sees the attribution is gone.)
|
||||||
|
|
||||||
|
This route is **not** linked from the UI (no nav entry, no win-screen button) — it's a direct-URL admin tool.
|
||||||
|
|
||||||
|
### `POST /api/submit-verse` (auth required)
|
||||||
|
|
||||||
|
**Body:** `{ bookId, chapter, verse, localDate }`
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Require `locals.user` (else `401`).
|
||||||
|
2. Solved-today gate: `dailyCompletions` row for `(anonymousId=user.id, date=localDate)` else `403`.
|
||||||
|
3. Cooldown: most recent `verse_submissions.submitted_at` for user; if `< 7d` ago → `429 { cooldownEndsAt }`.
|
||||||
|
4. Structural validation of `bookId/chapter/verse`.
|
||||||
|
5. Compute the 3-verse window (fall-forward algorithm).
|
||||||
|
6. Run the scheduling scan; on unique-constraint conflict, retry.
|
||||||
|
7. In a transaction: insert `daily_verses` row `{ id, date: D, bookId, reference, verseText, createdAt: now }` and `verse_submissions` row `{ id, user_id, scheduled_date: D, selected_book_id, selected_chapter, selected_verse, submitted_at: now }`.
|
||||||
|
8. Return `{ scheduledDate: D, reference, windowText }`.
|
||||||
|
|
||||||
|
Analytics: emit `(window as any).rybbit?.event("Submit a verse")`.
|
||||||
|
|
||||||
|
### `GET /api/submit-verse/status?localDate=YYYY-MM-DD` (auth required)
|
||||||
|
|
||||||
|
Returns the state for the win-screen button:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"canSubmit": true | false,
|
||||||
|
"cooldownEndsAt": <server UTC millis> | null,
|
||||||
|
"lastSubmission": { "scheduledDate": "2026-08-24", "reference": "..." } | null,
|
||||||
|
"upcoming": [ { "scheduledDate": "...", "reference": "..." }, ... ] // this user's not-yet-reached submissions, for an optional small list
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/bible/structure` (public)
|
||||||
|
|
||||||
|
Returns the static structure for populating the cascading dropdowns client-side (cached indefinitely):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "bookId": "gen", "chapters": [31, 25, 24, ...] }, // verse counts per chapter
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
Built from `getChapterCount` + `getVerseCount` over all 66 books. Small payload (~1189 chapter entries).
|
||||||
|
|
||||||
|
### `GET /api/verse-window?bookId=gen&chapter=1&verse=1` (public)
|
||||||
|
|
||||||
|
Returns the 3-verse preview for the live preview pane, computed via the fall-forward window algorithm:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"windowVerses": ["In the beginning...", "And the earth...", "And God said..."],
|
||||||
|
"reference": "Genesis 1:1-3",
|
||||||
|
"bookId": "gen",
|
||||||
|
"selectedVerse": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Called debounced (e.g. 250ms) as the selector changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### New component: `src/lib/components/SubmitVerse.svelte`
|
||||||
|
|
||||||
|
Rendered inside `WinScreen.svelte`, near the existing "📈 See your progress" button, sharing the same neobrutalist `.progress-btn` styling.
|
||||||
|
|
||||||
|
**Three visual states:**
|
||||||
|
|
||||||
|
1. **Logged out** — button reads "✨ Submit a verse" with a chevron; clicking expands a dropdown identical in structure to the existing logged-out progress dropdown: helper text ("Sign in to submit a verse for a future day") + Apple Sign-In form (`POST /auth/apple`, with hidden `anonymousId`) + Google Sign-In form (`POST /auth/google`). Mirrors the existing markup/classes exactly.
|
||||||
|
|
||||||
|
2. **Logged in, cooldown active** — button is greyed out / disabled ("✨ Submit a verse" + lock/⏳), with a small countdown timer below it counting down to `cooldownEndsAt` (reuse the `CountdownTimer` pattern / a compact timer). The dropdown does not expand.
|
||||||
|
|
||||||
|
3. **Logged in, can submit** — clicking expands a panel with:
|
||||||
|
- Three cascading native `<select>` dropdowns: **Book** (grouped by Testament, `<optgroup>`), **Chapter** (1..chapterCount), **Verse** (1..verseCount). Book list/chapter counts come from `/api/bible/structure` (fetched once, cached). Selecting book populates chapters; selecting chapter populates verses; selecting verse (or any change) triggers the debounced preview fetch.
|
||||||
|
- A **live preview** card showing the 3-verse window text + reference, updating in real time as the selector changes. (This is not today's verse — it's the user's prospective submission — so showing the book/reference is fine.)
|
||||||
|
- A **Submit** button (neobrutalist). On success, replace the panel with a confirmation: "✅ Your verse is scheduled for **August 24, 2026**" + the reference, plus a rybbit event. On `429`/`403`, show the server's error message inline.
|
||||||
|
|
||||||
|
The win screen already receives `isLoggedIn` and `anonymousId` as props; pass them through to `SubmitVerse`.
|
||||||
|
|
||||||
|
### Placement note
|
||||||
|
|
||||||
|
The button is **win-screen-only**, which implicitly requires the user to have solved today's puzzle to reach it (the win screen only renders post-solve, and reloads still render it via `game-persistence` restoring today's completed guesses). This matches the API-level solved-today gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reused Existing Functions
|
||||||
|
|
||||||
|
| Function | Used for |
|
||||||
|
| --- | --- |
|
||||||
|
| `getChapterCount(bookNumber)` | Dropdown chapter list bounds |
|
||||||
|
| `getVerseCount(bookNumber, chapter)` | Dropdown verse list bounds |
|
||||||
|
| `extractVerses(bookNumber, chapter, startVerse, count)` | Composing the 3-verse window (per chapter; concat for cross-chapter) |
|
||||||
|
| `getBookById` / `getBookByNumber` | Book id ↔ number ↔ name resolution |
|
||||||
|
| `formatReference(bookName, chapter, startVerse, endVerse)` | Reference formatting (extend for cross-chapter) |
|
||||||
|
| `getRandomVerses()` | Lazy gap-fill random verse (extended with neighbor avoidance) |
|
||||||
|
| `getVerseForDate(date)` | Returns pre-written submission row unchanged on its day |
|
||||||
|
| Auth (`locals.user`, session cookie) | Identifying the submitter |
|
||||||
|
| `dailyCompletions` (`anonymousId = user.id`, `date`) | Solved-today gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases & Decisions Log
|
||||||
|
|
||||||
|
- **Selected verse at a book's start (v1/v2 of chapter 1):** fall forward to `[v, v+1, v+2]`. Always 3 verses, same book.
|
||||||
|
- **Cross-chapter window within a book:** allowed; reference formatted as `Book C1:S1–C2:E2`.
|
||||||
|
- **Cross-book window:** never happens (algorithm constrains to one book).
|
||||||
|
- **Concurrent submissions picking the same date:** transaction + retry-on-unique-conflict.
|
||||||
|
- **Repeat of an identical 3-verse window:** allowed, but the scan skips any candidate date within ±60 days of an existing same-window row, so repeats land far out (this is what "schedule them out a month or two" means in practice).
|
||||||
|
- **Calendar drift (submissions outpace 1/day demand):** accepted — no guardrail; a submission may land far in the future. The submitter is shown the resulting date regardless.
|
||||||
|
- **Account deletion:** submissions are anonymous; `daily_verses` rows stay (canonical text). The `verse_submissions` row's `user_id` FK should be `ON DELETE SET NULL` (or cascade-delete the log row) — either is fine since attribution is never shown. The scheduled verse plays out regardless.
|
||||||
|
- **Discord daily-verse cron / RSS / sitemap:** unchanged — they call `getVerseForDate`, which serves pre-written submission rows transparently.
|
||||||
|
- **`createdAt` on submission-written `daily_verses` rows:** equals submit time, not the future play date. This is consistent with the existing field's meaning ("when the row was created").
|
||||||
|
- **Admin `/scheduled-verses` view is the sole attribution exception:** the public-facing anonymity guarantee does not apply to this admin-only route, which deliberately surfaces `user.email` for the `geohpowell@gmail.com` account. All other surfaces (win screen, share text, future potential leaderboard) remain anonymous.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open / Deferred (not blocking v1)
|
||||||
|
|
||||||
|
- A small "your upcoming scheduled verses" list on the win screen (data already available from `/api/submit-verse/status`). Nice-to-have, not required for v1.
|
||||||
|
- Balancing book distribution beyond no-back-to-back (e.g. avoiding clustering the same book within a month). Explicitly out of scope — only adjacent-day collisions are prevented.
|
||||||
|
- Email/notification when a user's scheduled verse goes live. Out of scope.
|
||||||
@@ -3,6 +3,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" />
|
||||||
|
<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">
|
||||||
|
|||||||
+3
-3
@@ -17,9 +17,9 @@ const handleAuth: Handle = async ({ event, resolve }) => {
|
|||||||
const { session, user } = await auth.validateSessionToken(sessionToken);
|
const { session, user } = await auth.validateSessionToken(sessionToken);
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
auth.setSessionTokenCookie({ cookies: event.cookies }, sessionToken, session.expiresAt);
|
||||||
} else {
|
} else {
|
||||||
auth.deleteSessionTokenCookie(event);
|
auth.deleteSessionTokenCookie({ cookies: event.cookies });
|
||||||
}
|
}
|
||||||
|
|
||||||
event.locals.user = user;
|
event.locals.user = user;
|
||||||
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
|||||||
|
|
||||||
// Initialize embeddings on server start (runs once on module load)
|
// Initialize embeddings on server start (runs once on module load)
|
||||||
const verses = getAllNKJVVerses();
|
const verses = getAllNKJVVerses();
|
||||||
await initializeEmbeddings(verses);
|
// await initializeEmbeddings(verses);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 248 204">
|
||||||
|
<path fill="#1d9bf0" d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 732 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Container from "$lib/components/Container.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
completions: Array<{ date: string; guessCount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { completions }: Props = $props();
|
||||||
|
|
||||||
|
type CalendarCell = {
|
||||||
|
date: string;
|
||||||
|
dayNum: number;
|
||||||
|
played: boolean;
|
||||||
|
guessCount: number | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type CalendarRow = {
|
||||||
|
cells: CalendarCell[];
|
||||||
|
monthLabel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildCalendar(
|
||||||
|
completionList: Array<{ date: string; guessCount: number }>,
|
||||||
|
localDate: string,
|
||||||
|
): CalendarRow[] {
|
||||||
|
const completionMap = new Map(
|
||||||
|
completionList.map((c) => [c.date, c.guessCount]),
|
||||||
|
);
|
||||||
|
const today = new Date(localDate + "T00:00:00Z");
|
||||||
|
|
||||||
|
const days: Array<{
|
||||||
|
date: string;
|
||||||
|
dayNum: number;
|
||||||
|
month: string;
|
||||||
|
dayOfWeek: number;
|
||||||
|
guessCount: number | null;
|
||||||
|
}> = [];
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setUTCDate(d.getUTCDate() - i);
|
||||||
|
const dateStr = d.toISOString().slice(0, 10);
|
||||||
|
days.push({
|
||||||
|
date: dateStr,
|
||||||
|
dayNum: d.getUTCDate(),
|
||||||
|
month: dateStr.slice(0, 7),
|
||||||
|
dayOfWeek: d.getUTCDay(),
|
||||||
|
guessCount: completionMap.get(dateStr) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: CalendarRow[] = [];
|
||||||
|
let currentRow: CalendarCell[] = [];
|
||||||
|
let currentMonth = "";
|
||||||
|
let firstRowOfMonth = true;
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
if (day.month !== currentMonth) {
|
||||||
|
if (currentRow.length > 0) {
|
||||||
|
while (currentRow.length < 7) currentRow.push(null);
|
||||||
|
rows.push({ cells: currentRow, monthLabel: null });
|
||||||
|
currentRow = [];
|
||||||
|
}
|
||||||
|
for (let j = 0; j < day.dayOfWeek; j++) currentRow.push(null);
|
||||||
|
currentMonth = day.month;
|
||||||
|
firstRowOfMonth = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRow.push({
|
||||||
|
date: day.date,
|
||||||
|
dayNum: day.dayNum,
|
||||||
|
played: day.guessCount !== null,
|
||||||
|
guessCount: day.guessCount ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentRow.length === 7) {
|
||||||
|
const [year, monthIdx] = currentMonth.split("-").map(Number);
|
||||||
|
const label = firstRowOfMonth
|
||||||
|
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
|
||||||
|
: null;
|
||||||
|
rows.push({ cells: currentRow, monthLabel: label });
|
||||||
|
currentRow = [];
|
||||||
|
firstRowOfMonth = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRow.length > 0) {
|
||||||
|
while (currentRow.length < 7) currentRow.push(null);
|
||||||
|
const [year, monthIdx] = currentMonth.split("-").map(Number);
|
||||||
|
rows.push({
|
||||||
|
cells: currentRow,
|
||||||
|
monthLabel: firstRowOfMonth
|
||||||
|
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarColor(day: {
|
||||||
|
played: boolean;
|
||||||
|
guessCount: number | null;
|
||||||
|
}): string {
|
||||||
|
if (!day.played) return "bg-gray-800/60 text-gray-400";
|
||||||
|
const g = day.guessCount!;
|
||||||
|
if (g === 1) return "bg-emerald-300";
|
||||||
|
if (g <= 3) return "bg-emerald-500";
|
||||||
|
if (g <= 5) return "bg-amber-400";
|
||||||
|
if (g <= 7) return "bg-orange-500";
|
||||||
|
return "bg-red-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
let calendarRows = $state<CalendarRow[]>([]);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
calendarRows = buildCalendar(completions, localDate);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container class="p-4 md:p-6 w-full">
|
||||||
|
<h2 class="text-xl font-bold text-gray-100 mb-3 w-full text-left">
|
||||||
|
Activity
|
||||||
|
</h2>
|
||||||
|
<!-- Day-of-week headers -->
|
||||||
|
<div class="flex gap-1 mb-1">
|
||||||
|
{#each ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as d (d)}
|
||||||
|
<div
|
||||||
|
class="w-10 h-5 text-center text-[10px] text-gray-500 font-medium shrink-0"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<!-- Calendar rows -->
|
||||||
|
{#each calendarRows as row, rowIdx (rowIdx)}
|
||||||
|
{#if row.monthLabel}
|
||||||
|
<div class="text-xs text-gray-400 font-semibold mt-3 mb-1">
|
||||||
|
{row.monthLabel}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-1 mb-1">
|
||||||
|
{#each row.cells as cell, cellIdx (cellIdx)}
|
||||||
|
{#if cell}
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded flex items-center justify-center text-sm font-semibold shrink-0 {calendarColor(
|
||||||
|
cell,
|
||||||
|
)}"
|
||||||
|
title={cell.played
|
||||||
|
? `${cell.date}: ${cell.guessCount} guess${cell.guessCount === 1 ? "" : "es"}`
|
||||||
|
: cell.date}
|
||||||
|
>
|
||||||
|
{cell.dayNum}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-10 h-10 shrink-0"></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-300"></span>
|
||||||
|
1 guess
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-500"></span>
|
||||||
|
2–3 guesses
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm bg-amber-400"></span>
|
||||||
|
4–5 guesses
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm bg-orange-500"></span>
|
||||||
|
6–7 guesses
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm bg-red-600"></span>
|
||||||
|
8+ guesses
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import Container from './Container.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
isOpen = $bindable(),
|
||||||
|
anonymousId = ''
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
anonymousId: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let mode = $state<'signin' | 'signup'>('signin');
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let success = $state('');
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let firstName = $state('');
|
||||||
|
let lastName = $state('');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
|
firstName = '';
|
||||||
|
lastName = '';
|
||||||
|
error = '';
|
||||||
|
success = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode() {
|
||||||
|
mode = mode === 'signin' ? 'signup' : 'signin';
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
isOpen = false;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
success = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResult(event: any) {
|
||||||
|
loading = false;
|
||||||
|
const result = event.result;
|
||||||
|
|
||||||
|
if (result.type === 'success') {
|
||||||
|
if (result.data?.success) {
|
||||||
|
success = mode === 'signin' ? 'Signed in successfully!' : 'Account created successfully!';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (browser) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else if (result.data?.error) {
|
||||||
|
error = result.data.error;
|
||||||
|
}
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
error = result.data?.error || 'An error occurred. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||||
|
<Container class="w-full max-w-md p-6 relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-white">
|
||||||
|
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/auth/apple">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
|
||||||
|
data-umami-event="Sign in with Apple"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Apple
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" action="/auth/google">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium mt-3"
|
||||||
|
data-umami-event="Sign in with Google"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||||
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex items-center my-4">
|
||||||
|
<div class="flex-1 border-t border-white/20"></div>
|
||||||
|
<span class="px-3 text-sm text-white/60">or</span>
|
||||||
|
<div class="flex-1 border-t border-white/20"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
if (anonymousId) {
|
||||||
|
formData.append('anonymousId', anonymousId);
|
||||||
|
}
|
||||||
|
handleSubmit();
|
||||||
|
return handleResult;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#if mode === 'signup'}
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="firstName" class="block text-sm font-medium text-white mb-1">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={firstName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||||
|
placeholder="John"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="lastName" class="block text-sm font-medium text-white mb-1">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={lastName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-white mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
bind:value={email}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-white mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
bind:value={password}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||||
|
placeholder="••••••••"
|
||||||
|
minlength="6"
|
||||||
|
/>
|
||||||
|
{#if mode === 'signup'}
|
||||||
|
<p class="text-xs text-white/80 mt-1">Minimum 6 characters</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p class="text-sm text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if success}
|
||||||
|
<div class="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<p class="text-sm text-green-600">{success}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full mt-6 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<p class="text-sm text-white">
|
||||||
|
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={switchMode}
|
||||||
|
class="text-blue-300 hover:text-blue-200 font-medium ml-1"
|
||||||
|
>
|
||||||
|
{mode === 'signin' ? 'Create one' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
type?: "button" | "submit" | "reset";
|
type?: "button" | "submit" | "reset";
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
onclick,
|
onclick,
|
||||||
class: className = "",
|
class: className = "",
|
||||||
type = "button",
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
<button
|
<button
|
||||||
{type}
|
{type}
|
||||||
{onclick}
|
{onclick}
|
||||||
|
{disabled}
|
||||||
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
|
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
|
||||||
variant
|
variant
|
||||||
]} {className}"
|
]} {className}"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
return chapterCounts[bookId] || 1;
|
return chapterCounts[bookId] || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6 random chapter options including the correct one
|
// Generate 4 random chapter options including the correct one
|
||||||
function generateChapterOptions(
|
function generateChapterOptions(
|
||||||
correctChapter: number,
|
correctChapter: number,
|
||||||
totalChapters: number,
|
totalChapters: number,
|
||||||
@@ -98,14 +98,14 @@
|
|||||||
const options = new Set<number>();
|
const options = new Set<number>();
|
||||||
options.add(correctChapter);
|
options.add(correctChapter);
|
||||||
|
|
||||||
if (totalChapters >= 6) {
|
if (totalChapters >= 4) {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter =
|
const randomChapter =
|
||||||
Math.floor(Math.random() * totalChapters) + 1;
|
Math.floor(Math.random() * totalChapters) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
@@ -167,18 +167,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 dark:from-amber-900/40 dark:to-yellow-900/30 text-gray-800 dark:text-gray-100 shadow-md"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
Bonus Challenge
|
||||||
Guess the chapter for an even higher grade
|
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||||
|
>— guess the chapter for an even higher grade</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
{#each chapterOptions as chapter (chapter)}
|
||||||
>
|
|
||||||
{#each chapterOptions as chapter}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => handleChapterSelect(chapter)}
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
disabled={hasAnswered}
|
disabled={hasAnswered}
|
||||||
@@ -193,8 +193,8 @@
|
|||||||
? isCorrect
|
? isCorrect
|
||||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
: "bg-red-400 text-white border-red-500"
|
: "bg-red-400 text-white border-red-500"
|
||||||
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
|
||||||
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
|
: "bg-white/80 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 text-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 hover:shadow-md cursor-pointer"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts" generics="T">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Header {
|
||||||
|
label: string;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mode {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: T[];
|
||||||
|
headers: Header[];
|
||||||
|
row: Snippet<[item: T]>;
|
||||||
|
empty?: Snippet;
|
||||||
|
initialRows?: number;
|
||||||
|
modes?: Mode[];
|
||||||
|
mode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
rows,
|
||||||
|
headers,
|
||||||
|
row: rowSnippet,
|
||||||
|
empty,
|
||||||
|
initialRows = 3,
|
||||||
|
modes,
|
||||||
|
mode = $bindable(modes && modes.length > 0 ? modes[0].value : undefined),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
// Reset expanded when mode changes (e.g. switching Rolling 30d ↔ Calendar)
|
||||||
|
$effect(() => {
|
||||||
|
mode;
|
||||||
|
expanded = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedRows = $derived(expanded ? rows : rows.slice(0, initialRows));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if modes && modes.length > 1}
|
||||||
|
<div class="flex gap-1 bg-white/5 rounded-lg p-1 w-fit ml-auto mb-3">
|
||||||
|
{#each modes as m (m.value)}
|
||||||
|
{@const active = mode === m.value}
|
||||||
|
<button
|
||||||
|
onclick={() => (mode = m.value)}
|
||||||
|
class="px-3 py-1 text-xs rounded-md transition-colors {active ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if rows.length === 0}
|
||||||
|
{#if empty}
|
||||||
|
{@render empty()}
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||||
|
{#each headers as header (header.label)}
|
||||||
|
<th
|
||||||
|
class="{header.align === 'right' ? 'text-right' : 'text-left'} px-4 py-3{header.width ? ' ' + header.width : ''}"
|
||||||
|
>
|
||||||
|
{header.label}
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayedRows as item, i (i)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
{@render rowSnippet(item)}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rows.length > initialRows}
|
||||||
|
<button
|
||||||
|
onclick={toggleExpanded}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{expanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}"
|
class="inline-flex flex-col items-center bg-white/10 dark:bg-white/5 backdrop-blur-sm rounded-2xl border border-white/20 dark:border-white/10 shadow-sm {className}"
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,88 +1,97 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
let timeUntilNext = $state("");
|
let timeUntilNext = $state("");
|
||||||
let intervalId: number | null = null;
|
let newVerseReady = $state(false);
|
||||||
|
let showEncouragement = $state(false);
|
||||||
|
let intervalId: number | null = null;
|
||||||
|
let targetTime = 0;
|
||||||
|
|
||||||
function calculateTimeUntilFivePM(): string {
|
function initTarget() {
|
||||||
const now = new Date();
|
const target = new Date();
|
||||||
const target = new Date(now);
|
target.setHours(0, 0, 0, 0);
|
||||||
|
if (Date.now() >= target.getTime()) {
|
||||||
|
target.setDate(target.getDate() + 1);
|
||||||
|
}
|
||||||
|
targetTime = target.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
// Set target to 5:00 PM today
|
function updateTimer() {
|
||||||
target.setHours(17, 0, 0, 0);
|
const diff = targetTime - Date.now();
|
||||||
|
|
||||||
// If it's already past 5:00 PM, set target to tomorrow 5:00 PM
|
if (diff <= 0) {
|
||||||
if (now.getTime() >= target.getTime()) {
|
newVerseReady = true;
|
||||||
target.setDate(target.getDate() + 1);
|
timeUntilNext = "";
|
||||||
}
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const diff = target.getTime() - now.getTime();
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
if (diff <= 0) {
|
timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
|
||||||
return "00:00:00";
|
.toString()
|
||||||
}
|
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
onMount(() => {
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
initTarget();
|
||||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
updateTimer();
|
||||||
|
intervalId = window.setInterval(updateTimer, 1000);
|
||||||
|
|
||||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
const winCount = Object.keys(localStorage).filter(
|
||||||
.toString()
|
(k) =>
|
||||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
k.startsWith("bibdle-win-tracked-") &&
|
||||||
}
|
localStorage.getItem(k) === "true",
|
||||||
|
).length;
|
||||||
|
showEncouragement = winCount < 3;
|
||||||
|
});
|
||||||
|
|
||||||
function calculateTimeUntilMidnight(): string {
|
onDestroy(() => {
|
||||||
const now = new Date();
|
if (intervalId) {
|
||||||
const target = new Date(now);
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
// Set target to midnight today
|
});
|
||||||
target.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// If it's already past midnight, set target to tomorrow midnight
|
|
||||||
if (now.getTime() >= target.getTime()) {
|
|
||||||
target.setDate(target.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const diff = target.getTime() - now.getTime();
|
|
||||||
|
|
||||||
if (diff <= 0) {
|
|
||||||
return "00:00:00";
|
|
||||||
}
|
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTimer() {
|
|
||||||
timeUntilNext = calculateTimeUntilMidnight();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateTimer();
|
|
||||||
intervalId = window.setInterval(updateTimer, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full flex flex-col flex-1">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm w-full flex-1"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
|
{#if newVerseReady}
|
||||||
Next Verse In
|
<p
|
||||||
</p>
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
>
|
||||||
{timeUntilNext}
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||||
|
<p
|
||||||
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mt-2"
|
||||||
|
>
|
||||||
|
(refresh page to see the new verse)
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p
|
||||||
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
|
>
|
||||||
|
Next Verse In
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{timeUntilNext}
|
||||||
|
</p>
|
||||||
|
{#if showEncouragement}
|
||||||
|
<p
|
||||||
|
class="text-xs text-center uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold px-4 mt-3"
|
||||||
|
>
|
||||||
|
Come back tomorrow for a new verse!
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center gap-2 bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
class="flex flex-col items-center gap-2 bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
<p
|
||||||
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300 font-bold"
|
||||||
|
>
|
||||||
A project by George Powell & Silent Summit Co.
|
A project by George Powell & Silent Summit Co.
|
||||||
</p>
|
</p>
|
||||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||||
@@ -25,38 +27,8 @@
|
|||||||
<!-- Bluesky Social Media Button -->
|
<!-- Bluesky Social Media Button -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex items-center justify-center gap-6">
|
<div class="mt-8">
|
||||||
<a
|
<SocialLinks />
|
||||||
href="https://bsky.app/profile/snail.city"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
|
||||||
aria-label="Follow on Bluesky"
|
|
||||||
>
|
|
||||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="mailto:george+bibdle@silentsummit.co"
|
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
|
||||||
aria-label="Send email"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8 text-gray-700"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,44 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
import Button from "$lib/components/Button.svelte";
|
import Button from "$lib/components/Button.svelte";
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: string;
|
||||||
|
email?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
appleId?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
let {
|
||||||
|
anonymousId,
|
||||||
|
user,
|
||||||
|
onSignIn,
|
||||||
|
}: { anonymousId: string | null; user: User; onSignIn: () => void } = $props();
|
||||||
|
|
||||||
|
let seeding = $state(false);
|
||||||
|
|
||||||
|
async function seedHistory(days: number = 10) {
|
||||||
|
if (!browser || !anonymousId || seeding) return;
|
||||||
|
seeding = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/dev/seed-history", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ anonymousId, days })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
alert(
|
||||||
|
`Seeded! Inserted: ${result.inserted?.join(", ")}. Skipped (already exist): ${result.skipped?.join(", ") || "none"}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to seed history");
|
||||||
|
} finally {
|
||||||
|
seeding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearLocalStorage() {
|
function clearLocalStorage() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
// Clear all bibdle-related localStorage items
|
// Clear all bibdle-related localStorage items
|
||||||
@@ -22,6 +59,41 @@
|
|||||||
<div class="border-t-2 border-gray-400"></div>
|
<div class="border-t-2 border-gray-400"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
|
<a
|
||||||
|
href="/stats?{user
|
||||||
|
? `userId=${user.id}`
|
||||||
|
: `anonymousId=${anonymousId}`}&tz={encodeURIComponent(
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
)}"
|
||||||
|
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
📊 View Stats
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/progress"
|
||||||
|
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
📈 View Progress
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<form method="POST" action="/auth/logout" use:enhance class="w-full">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
🚪 Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={onSignIn}
|
||||||
|
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
🔐 Sign In
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -86,4 +158,21 @@
|
|||||||
>
|
>
|
||||||
Clear LocalStorage
|
Clear LocalStorage
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={() => seedHistory(1)}
|
||||||
|
disabled={seeding}
|
||||||
|
class="w-full py-4 md:py-2"
|
||||||
|
>
|
||||||
|
{seeding ? "Seeding..." : "Add 1 Day of History"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={() => seedHistory(10)}
|
||||||
|
disabled={seeding}
|
||||||
|
class="w-full py-4 md:py-2"
|
||||||
|
>
|
||||||
|
{seeding ? "Seeding..." : "Seed 10 Days of History"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { guessCount }: { guessCount: number } = $props();
|
||||||
|
|
||||||
|
let promptText = $state("What book of the Bible is this verse from?");
|
||||||
|
let visible = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let fadeOutId: ReturnType<typeof setTimeout>;
|
||||||
|
let changeId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function animateTo(newText: string, delay = 0) {
|
||||||
|
fadeOutId = setTimeout(() => {
|
||||||
|
visible = false;
|
||||||
|
changeId = setTimeout(() => {
|
||||||
|
promptText = newText;
|
||||||
|
visible = true;
|
||||||
|
}, 300);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guessCount === 0) {
|
||||||
|
animateTo("What book of the Bible is this verse from?");
|
||||||
|
} else {
|
||||||
|
animateTo("Guess again", 2100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(fadeOutId);
|
||||||
|
clearTimeout(changeId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="big-text text-center text-gray-800 dark:text-gray-100 mb-6 px-4"
|
||||||
|
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||||
|
>
|
||||||
|
{promptText}
|
||||||
|
</p>
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks } from "$lib/types/bible";
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
import Container from "./Container.svelte";
|
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||||
|
|
||||||
interface Guess {
|
|
||||||
book: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
testament: string;
|
|
||||||
section: string;
|
|
||||||
};
|
|
||||||
testamentMatch: boolean;
|
|
||||||
sectionMatch: boolean;
|
|
||||||
adjacent: boolean;
|
|
||||||
firstLetterMatch: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
guesses,
|
guesses,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
}: { guesses: Guess[]; correctBookId: string } = $props();
|
minimized = false,
|
||||||
|
}: { guesses: Guess[]; correctBookId: string; minimized?: boolean } = $props();
|
||||||
|
|
||||||
let hasGuesses = $derived(guesses.length > 0);
|
let hasGuesses = $derived(guesses.length > 0);
|
||||||
|
let showMinimized = $derived(minimized);
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!minimized) expanded = false;
|
||||||
|
});
|
||||||
|
|
||||||
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
|
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
|
||||||
if (isCorrect) return "bg-green-500 border-green-600";
|
if (isCorrect) return "bg-green-500 border-green-600";
|
||||||
@@ -28,9 +22,11 @@
|
|||||||
return "bg-red-500 border-red-600";
|
return "bg-red-500 border-red-600";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstLetter(bookName: string): string {
|
function getBookBoxStyle(guess: Guess): string {
|
||||||
const match = bookName.match(/[a-zA-Z]/);
|
if (guess.book.id === correctBookId) {
|
||||||
return match ? match[0] : bookName[0];
|
return "background-color: #22c55e; border-color: #16a34a;";
|
||||||
|
}
|
||||||
|
return "background-color: #ef4444; border-color: #dc2626;";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBoxContent(
|
function getBoxContent(
|
||||||
@@ -81,82 +77,63 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !hasGuesses}
|
{#if hasGuesses}
|
||||||
<Container class="p-6 text-center">
|
|
||||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
|
|
||||||
Instructions
|
|
||||||
</h2>
|
|
||||||
<p class="text-gray-700 leading-relaxed italic">
|
|
||||||
Guess what book of the bible you think the verse is from. You will
|
|
||||||
get clues to tell you if your guess is close or not. Green means the
|
|
||||||
category is correct; red means wrong.
|
|
||||||
</p>
|
|
||||||
</Container>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Column Headers -->
|
<!-- Column Headers -->
|
||||||
<div
|
<div
|
||||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
|
||||||
Book
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
|
||||||
>
|
>
|
||||||
Testament
|
Testament
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
Section
|
Section
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
First Letter
|
First Letter
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Book
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
{#if showMinimized && !expanded}
|
||||||
<div class="flex gap-2 justify-center">
|
<!-- Minimized view: first guess, divider, last two guesses -->
|
||||||
<!-- Book Column -->
|
|
||||||
<div
|
|
||||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
|
|
||||||
guess.book.id === correctBookId,
|
|
||||||
)}"
|
|
||||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
|
||||||
>
|
|
||||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
|
||||||
>{getBoxContent(guess, "book")}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- First guess (no animation since post-win) -->
|
||||||
|
{@const firstGuess = guesses[0]}
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
<!-- Testament Column -->
|
<!-- Testament Column -->
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
guess.testamentMatch,
|
firstGuess.testamentMatch,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "testament")}</span
|
>{getBoxContent(firstGuess, "testament")}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Column -->
|
<!-- Section Column -->
|
||||||
<div
|
<div
|
||||||
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
guess.sectionMatch,
|
firstGuess.sectionMatch,
|
||||||
guess.adjacent,
|
firstGuess.adjacent,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "section")}
|
>{getBoxContent(firstGuess, "section")}
|
||||||
{#if guess.adjacent}
|
{#if firstGuess.adjacent}
|
||||||
‼️
|
‼️
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -165,18 +142,173 @@
|
|||||||
<!-- First Letter Column -->
|
<!-- First Letter Column -->
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
guess.firstLetterMatch,
|
firstGuess.firstLetterMatch,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "firstLetter")}</span
|
>{getBoxContent(firstGuess, "firstLetter")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Book Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||||
|
style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(firstGuess)}"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
|
>{getBoxContent(firstGuess, "book")}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
|
||||||
|
<!-- Expand/collapse divider -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
|
||||||
|
onclick={() => (expanded = true)}
|
||||||
|
>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
|
||||||
|
expand guesses ▼
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Last two guesses (no animation since post-win) -->
|
||||||
|
{#each guesses.slice(-2) as guess (guess.book.id)}
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<!-- Testament Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.testamentMatch,
|
||||||
|
)}"
|
||||||
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "testament")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Column -->
|
||||||
|
<div
|
||||||
|
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.sectionMatch,
|
||||||
|
guess.adjacent,
|
||||||
|
)}"
|
||||||
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "section")}
|
||||||
|
{#if guess.adjacent}
|
||||||
|
‼️
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Letter Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.firstLetterMatch,
|
||||||
|
)}"
|
||||||
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "firstLetter")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Book Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||||
|
style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(guess)}"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
|
>{getBoxContent(guess, "book")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<!-- Full view: all guesses -->
|
||||||
|
|
||||||
|
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<!-- Testament Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.testamentMatch,
|
||||||
|
)}"
|
||||||
|
style={showMinimized
|
||||||
|
? "animation: none; opacity: 1; transform: none;"
|
||||||
|
: `animation-delay: ${rowIndex * 1000 + 0 * 500}ms`}
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "testament")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Column -->
|
||||||
|
<div
|
||||||
|
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.sectionMatch,
|
||||||
|
guess.adjacent,
|
||||||
|
)}"
|
||||||
|
style={showMinimized
|
||||||
|
? "animation: none; opacity: 1; transform: none;"
|
||||||
|
: `animation-delay: ${rowIndex * 1000 + 1 * 500}ms`}
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "section")}
|
||||||
|
{#if guess.adjacent}
|
||||||
|
‼️
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Letter Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.firstLetterMatch,
|
||||||
|
)}"
|
||||||
|
style={showMinimized
|
||||||
|
? "animation: none; opacity: 1; transform: none;"
|
||||||
|
: `animation-delay: ${rowIndex * 1000 + 2 * 500}ms`}
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "firstLetter")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Book Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||||
|
style={showMinimized
|
||||||
|
? `animation: none; opacity: 1; transform: none; ${getBookBoxStyle(guess)}`
|
||||||
|
: `animation-delay: ${rowIndex * 1000 + 3 * 500}ms; ${getBookBoxStyle(guess)}`}
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
|
>{getBoxContent(guess, "book")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if showMinimized && expanded && rowIndex === 0}
|
||||||
|
<!-- Collapse divider shown right below the final (correct) guess -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
|
||||||
|
onclick={() => (expanded = false)}
|
||||||
|
>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
|
||||||
|
collapse guesses ▲
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- </div> -->
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
imposterIndex: number;
|
imposterIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: ImposterData | null = null;
|
let data: ImposterData | null = $state(null);
|
||||||
let clicked: boolean[] = [];
|
let clicked: boolean[] = $state([]);
|
||||||
let gameOver = false;
|
let gameOver = $state(false);
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
let error: string | null = null;
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
<p>Error: {error}</p>
|
<p>Error: {error}</p>
|
||||||
<button on:click={newGame}>Retry</button>
|
<button onclick={newGame}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
<!-- <div class="instructions">
|
<!-- <div class="instructions">
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
class:clicked={clicked[i]}
|
class:clicked={clicked[i]}
|
||||||
class:correct={clicked[i] && i === data.imposterIndex}
|
class:correct={clicked[i] && i === data.imposterIndex}
|
||||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||||
on:click={() => handleClick(i)}
|
onclick={() => handleClick(i)}
|
||||||
disabled={gameOver}
|
disabled={gameOver}
|
||||||
>
|
>
|
||||||
{formatVerse(verse)}
|
{formatVerse(verse)}
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<button on:click={newGame}>New Game</button>
|
<button onclick={newGame}>New Game</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from "$lib/components/Container.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
emoji: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
colorClass: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { emoji, value, label, colorClass, suffix }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container class="p-4 md:p-6 w-full">
|
||||||
|
<div class="text-center w-full">
|
||||||
|
<div class="text-2xl md:text-3xl mb-1">{emoji}</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold {colorClass} mb-1">
|
||||||
|
{value}{#if suffix}<span class="text-base font-normal text-gray-400"
|
||||||
|
> {suffix}</span
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs md:text-sm text-gray-300 font-medium">{label}</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
@@ -1,92 +1,355 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
import {
|
||||||
|
bibleBooks,
|
||||||
|
type BibleBook,
|
||||||
|
type BibleSection,
|
||||||
|
type Testament,
|
||||||
|
} from "$lib/types/bible";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
|
let {
|
||||||
|
searchQuery = $bindable(""),
|
||||||
|
guessedIds,
|
||||||
|
submitGuess,
|
||||||
|
guessCount = 0,
|
||||||
|
}: {
|
||||||
|
searchQuery: string;
|
||||||
|
guessedIds: SvelteSet<string>;
|
||||||
|
submitGuess: (id: string) => void;
|
||||||
|
guessCount: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let filteredBooks = $derived(
|
type DisplayMode = "simple" | "testament" | "sections";
|
||||||
bibleBooks.filter((book) =>
|
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
const displayMode = $derived<DisplayMode>(
|
||||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
|
||||||
submitGuess(filteredBooks[0].id);
|
);
|
||||||
}
|
|
||||||
}
|
const filteredBooks = $derived(
|
||||||
|
bibleBooks.filter((book) =>
|
||||||
|
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
type SimpleGroup = { books: BibleBook[] };
|
||||||
|
|
||||||
|
type TestamentGroup = {
|
||||||
|
testament: Testament;
|
||||||
|
label: string;
|
||||||
|
books: BibleBook[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SectionGroup = {
|
||||||
|
testament: Testament;
|
||||||
|
testamentLabel: string;
|
||||||
|
showTestamentHeader: boolean;
|
||||||
|
section: BibleSection;
|
||||||
|
books: BibleBook[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||||
|
const sorted = [...filteredBooks].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
return { books: sorted };
|
||||||
|
});
|
||||||
|
|
||||||
|
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||||
|
const old = filteredBooks
|
||||||
|
.filter((b) => b.testament === "old")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const newT = filteredBooks
|
||||||
|
.filter((b) => b.testament === "new")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const groups: TestamentGroup[] = [];
|
||||||
|
if (old.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
testament: "old",
|
||||||
|
label: "Old Testament",
|
||||||
|
books: old,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (newT.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
testament: "new",
|
||||||
|
label: "New Testament",
|
||||||
|
books: newT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||||
|
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||||
|
const seenKeys: Record<string, true> = {};
|
||||||
|
const orderedPairs: { testament: Testament; section: BibleSection }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
const key = `${book.testament}:${book.section}`;
|
||||||
|
if (!seenKeys[key]) {
|
||||||
|
seenKeys[key] = true;
|
||||||
|
orderedPairs.push({
|
||||||
|
testament: book.testament,
|
||||||
|
section: book.section,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: SectionGroup[] = [];
|
||||||
|
let lastTestament: Testament | null = null;
|
||||||
|
|
||||||
|
for (const pair of orderedPairs) {
|
||||||
|
const books = filteredBooks.filter(
|
||||||
|
(b) =>
|
||||||
|
b.testament === pair.testament &&
|
||||||
|
b.section === pair.section,
|
||||||
|
);
|
||||||
|
if (books.length === 0) continue;
|
||||||
|
|
||||||
|
const showTestamentHeader = pair.testament !== lastTestament;
|
||||||
|
lastTestament = pair.testament;
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
testament: pair.testament,
|
||||||
|
testamentLabel:
|
||||||
|
pair.testament === "old"
|
||||||
|
? "Old Testament"
|
||||||
|
: "New Testament",
|
||||||
|
showTestamentHeader,
|
||||||
|
section: pair.section,
|
||||||
|
books,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
// First book in display order for Enter key submission
|
||||||
|
const firstBookId = $derived.by<string | null>(() => {
|
||||||
|
if (filteredBooks.length === 0) return null;
|
||||||
|
if (displayMode === "simple") {
|
||||||
|
return simpleGroup.books[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (displayMode === "testament") {
|
||||||
|
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && firstBookId) {
|
||||||
|
submitGuess(firstBookId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const showBanner = $derived(guessCount >= 3);
|
||||||
|
const showBanner = false;
|
||||||
|
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if showBanner}
|
||||||
|
<p
|
||||||
|
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{#if bannerIsIndigo}
|
||||||
|
Testament & section groups now visible
|
||||||
|
{:else}
|
||||||
|
Old & New Testament groups now visible
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
aria-hidden="true"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
stroke-width="2"
|
||||||
/>
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
</svg>
|
/>
|
||||||
<input
|
</svg>
|
||||||
bind:value={searchQuery}
|
<input
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
bind:value={searchQuery}
|
||||||
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
|
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||||
onkeydown={handleKeydown}
|
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||||
autocomplete="off"
|
onkeydown={handleKeydown}
|
||||||
/>
|
autocomplete="off"
|
||||||
{#if searchQuery}
|
/>
|
||||||
<button
|
{#if searchQuery}
|
||||||
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
<button
|
||||||
onclick={() => (searchQuery = "")}
|
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||||
aria-label="Clear search"
|
onclick={() => (searchQuery = "")}
|
||||||
>
|
aria-label="Clear search"
|
||||||
<svg
|
>
|
||||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
<svg
|
||||||
fill="none"
|
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
viewBox="0 0 24 24"
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path
|
aria-hidden="true"
|
||||||
stroke-linecap="round"
|
>
|
||||||
stroke-linejoin="round"
|
<path
|
||||||
stroke-width="2"
|
stroke-linecap="round"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
stroke-linejoin="round"
|
||||||
/>
|
stroke-width="2"
|
||||||
</svg>
|
d="M6 18L18 6M6 6l12 12"
|
||||||
</button>
|
/>
|
||||||
{/if}
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{/if}
|
||||||
<ul
|
</div>
|
||||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
|
||||||
>
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
{#each filteredBooks as book (book.id)}
|
<ul
|
||||||
<li>
|
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
|
||||||
<button
|
role="listbox"
|
||||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
>
|
||||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
{#if displayMode === "simple"}
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
{#each simpleGroup.books as book (book.id)}
|
||||||
onclick={() => submitGuess(book.id)}
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
>
|
<button
|
||||||
<span
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
{guessedIds.has(book.id)
|
||||||
? 'line-through text-gray-500'
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
: ''}">{book.name}</span
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
>
|
onclick={() => submitGuess(book.id)}
|
||||||
<span class="ml-auto text-sm opacity-75"
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
>({book.testament.toUpperCase()})</span
|
>
|
||||||
>
|
<span
|
||||||
</button>
|
class="font-semibold dark:text-gray-100 {guessedIds.has(
|
||||||
</li>
|
book.id,
|
||||||
{/each}
|
)
|
||||||
</ul>
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
{:else if searchQuery}
|
: ''}"
|
||||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
>
|
||||||
{/if}
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else if displayMode === "testament"}
|
||||||
|
{#each testamentGroups as group (group.testament)}
|
||||||
|
<li role="presentation">
|
||||||
|
<div
|
||||||
|
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each group.books as book (book.id)}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={guessedIds.has(book.id)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||||
|
{guessedIds.has(book.id)
|
||||||
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||||
|
onclick={() => submitGuess(book.id)}
|
||||||
|
tabindex={guessedIds.has(book.id)
|
||||||
|
? -1
|
||||||
|
: 0}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-semibold {guessedIds.has(
|
||||||
|
book.id,
|
||||||
|
)
|
||||||
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||||
|
<li role="presentation">
|
||||||
|
{#if group.showTestamentHeader}
|
||||||
|
<div
|
||||||
|
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{group.testamentLabel}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
{group.section}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-100 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each group.books as book (book.id)}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={guessedIds.has(book.id)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||||
|
{guessedIds.has(book.id)
|
||||||
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||||
|
onclick={() => submitGuess(book.id)}
|
||||||
|
tabindex={guessedIds.has(book.id)
|
||||||
|
? -1
|
||||||
|
: 0}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-semibold {guessedIds.has(
|
||||||
|
book.id,
|
||||||
|
)
|
||||||
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{:else if searchQuery}
|
||||||
|
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">
|
||||||
|
No books found
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||||
|
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-6">
|
||||||
|
<a
|
||||||
|
href="https://bsky.app/profile/snail.city"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="Follow on Bluesky"
|
||||||
|
data-umami-event="Bluesky clicked"
|
||||||
|
>
|
||||||
|
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div>
|
||||||
|
|
||||||
|
<!-- <a
|
||||||
|
href="https://x.com/pupperpowell"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="Follow on Twitter"
|
||||||
|
data-umami-event="Twitter clicked"
|
||||||
|
>
|
||||||
|
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="mailto:george@snail.city"
|
||||||
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="Send email"
|
||||||
|
data-umami-event="Email clicked"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
streak,
|
||||||
|
streakPercentile = null,
|
||||||
|
}: {
|
||||||
|
streak: number;
|
||||||
|
streakPercentile?: number | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm flex-1 text-center"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-5xl font-triodion font-black text-orange-500 leading-none tabular-nums"
|
||||||
|
>
|
||||||
|
{streak}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-xs uppercase justify-center tracking-widest text-gray-500 dark:text-gray-200 font-triodion font-bold py-2 leading-tight"
|
||||||
|
>
|
||||||
|
day{streak === 1 ? "" : "s"} in a row
|
||||||
|
</p>
|
||||||
|
{#if streakPercentile !== null && streakPercentile <= 50}
|
||||||
|
<p
|
||||||
|
class="text-xs text-black dark:text-gray-200 w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 dark:border-t-stone-600 pt-2"
|
||||||
|
>
|
||||||
|
Top {streakPercentile}%
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,995 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
|
|
||||||
|
let {
|
||||||
|
isLoggedIn = false,
|
||||||
|
anonymousId = "",
|
||||||
|
localDate = "",
|
||||||
|
}: {
|
||||||
|
isLoggedIn?: boolean;
|
||||||
|
anonymousId?: string;
|
||||||
|
localDate?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// ── Status (logged-in state) ───────────────────────────────────────────
|
||||||
|
type Status = {
|
||||||
|
canSubmit: boolean;
|
||||||
|
cooldownEndsAt: number | null;
|
||||||
|
lastSubmission: { scheduledDate: string; reference: string } | null;
|
||||||
|
upcoming: { scheduledDate: string; reference: string }[];
|
||||||
|
};
|
||||||
|
let status = $state<Status | null>(null);
|
||||||
|
let statusError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// ── Bible structure (Book → chapters → verse counts) ──────────────────
|
||||||
|
type Structure = { bookId: string; chapters: number[] }[];
|
||||||
|
let structure = $state<Structure | null>(null);
|
||||||
|
|
||||||
|
// ── Selector state ─────────────────────────────────────────────────────
|
||||||
|
let selectedBookId = $state<string>("");
|
||||||
|
let selectedChapter = $state<number>(0);
|
||||||
|
let selectedVerse = $state<number>(0);
|
||||||
|
|
||||||
|
// ── Preview state ──────────────────────────────────────────────────────
|
||||||
|
type Preview = {
|
||||||
|
windowVerses: string[];
|
||||||
|
reference: string;
|
||||||
|
bookId: string;
|
||||||
|
selectedVerse: number;
|
||||||
|
};
|
||||||
|
let preview = $state<Preview | null>(null);
|
||||||
|
let previewLoading = $state(false);
|
||||||
|
|
||||||
|
// ── Submission state ───────────────────────────────────────────────────
|
||||||
|
let submitting = $state(false);
|
||||||
|
let submitError = $state<string | null>(null);
|
||||||
|
let submitResult = $state<{ scheduledDate: string; reference: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── UI state ───────────────────────────────────────────────────────────
|
||||||
|
let expanded = $state(false);
|
||||||
|
let countdownText = $state("");
|
||||||
|
let countdownId: number | null = null;
|
||||||
|
|
||||||
|
// Book list grouped by Testament for the <select> optgroups.
|
||||||
|
const oldBooks = $derived(bibleBooks.filter((b) => b.testament === "old"));
|
||||||
|
const newBooks = $derived(bibleBooks.filter((b) => b.testament === "new"));
|
||||||
|
|
||||||
|
// Chapter list for the currently selected book (1..N).
|
||||||
|
const chapterCount = $derived(
|
||||||
|
structure && selectedBookId
|
||||||
|
? structure.find((b) => b.bookId === selectedBookId)?.chapters.length ??
|
||||||
|
0
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
// Verse count for the currently selected chapter.
|
||||||
|
const verseCount = $derived(
|
||||||
|
structure && selectedBookId && selectedChapter
|
||||||
|
? structure.find((b) => b.bookId === selectedBookId)?.chapters[
|
||||||
|
selectedChapter - 1
|
||||||
|
] ?? 0
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Whether the user is currently on cooldown (only meaningful when logged in).
|
||||||
|
const onCooldown = $derived(
|
||||||
|
!!status && !status.canSubmit && status.cooldownEndsAt !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounce handle for the live preview fetch.
|
||||||
|
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function trackEvent(name: string) {
|
||||||
|
try {
|
||||||
|
(window as any).rybbit?.event?.(name);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateLong(dateStr: string): string {
|
||||||
|
// dateStr is YYYY-MM-DD; format as "August 24, 2026" in UTC to avoid drift.
|
||||||
|
const d = new Date(dateStr + "T00:00:00Z");
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateVague(dateStr: string): string {
|
||||||
|
// dateStr is YYYY-MM-DD. Return a deliberately vague timeframe instead of
|
||||||
|
// revealing the exact scheduled date.
|
||||||
|
const scheduled = new Date(dateStr + "T00:00:00Z");
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||||
|
);
|
||||||
|
const diffDays = Math.round(
|
||||||
|
(scheduled.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
if (diffDays <= 14) return "a few days from now";
|
||||||
|
if (diffDays <= 60) return "a few weeks from now";
|
||||||
|
return "within the next few months";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
if (!isLoggedIn || !localDate) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/submit-verse/status?localDate=${encodeURIComponent(localDate)}`,
|
||||||
|
);
|
||||||
|
if (res.status === 401) {
|
||||||
|
status = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
statusError = "Couldn't load submission status.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status = await res.json();
|
||||||
|
} catch {
|
||||||
|
statusError = "Couldn't load submission status.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStructure() {
|
||||||
|
if (structure) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bible/structure");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
structure = data;
|
||||||
|
// Default selection: first book, chapter 1, verse 1.
|
||||||
|
if (data.length > 0 && !selectedBookId) {
|
||||||
|
selectedBookId = data[0].bookId;
|
||||||
|
selectedChapter = 1;
|
||||||
|
selectedVerse = 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchPreview() {
|
||||||
|
if (previewTimer) clearTimeout(previewTimer);
|
||||||
|
previewTimer = setTimeout(async () => {
|
||||||
|
if (!selectedBookId || !selectedChapter || !selectedVerse) {
|
||||||
|
preview = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/verse-window?bookId=${encodeURIComponent(
|
||||||
|
selectedBookId,
|
||||||
|
)}&chapter=${selectedChapter}&verse=${selectedVerse}`,
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
preview = await res.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
} finally {
|
||||||
|
previewLoading = false;
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBookChange() {
|
||||||
|
selectedChapter = 1;
|
||||||
|
selectedVerse = 1;
|
||||||
|
submitError = null;
|
||||||
|
fetchPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChapterChange() {
|
||||||
|
selectedVerse = 1;
|
||||||
|
submitError = null;
|
||||||
|
fetchPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVerseChange() {
|
||||||
|
submitError = null;
|
||||||
|
fetchPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!isLoggedIn) return;
|
||||||
|
submitting = true;
|
||||||
|
submitError = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/submit-verse", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
bookId: selectedBookId,
|
||||||
|
chapter: selectedChapter,
|
||||||
|
verse: selectedVerse,
|
||||||
|
localDate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 429 && data?.cooldownEndsAt) {
|
||||||
|
// Refresh status so the cooldown UI kicks in.
|
||||||
|
await loadStatus();
|
||||||
|
submitError = "You're on cooldown — try again later.";
|
||||||
|
} else {
|
||||||
|
submitError = data?.error ?? "Submission failed.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitResult = {
|
||||||
|
scheduledDate: data.scheduledDate,
|
||||||
|
reference: data.reference,
|
||||||
|
};
|
||||||
|
trackEvent("Submit a verse");
|
||||||
|
// Refresh status so the cooldown reflects the new submission.
|
||||||
|
await loadStatus();
|
||||||
|
} catch {
|
||||||
|
submitError = "Network error — please try again.";
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
if (!status?.cooldownEndsAt) {
|
||||||
|
countdownText = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const diff = status.cooldownEndsAt - Date.now();
|
||||||
|
if (diff <= 0) {
|
||||||
|
countdownText = "";
|
||||||
|
// Cooldown elapsed — refresh status once.
|
||||||
|
loadStatus();
|
||||||
|
if (countdownId) {
|
||||||
|
clearInterval(countdownId);
|
||||||
|
countdownId = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
countdownText =
|
||||||
|
(days > 0 ? `${days}d ` : "") +
|
||||||
|
`${hours.toString().padStart(2, "0")}h ${minutes
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loadStatus();
|
||||||
|
loadStructure();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start/stop the countdown timer as cooldown state changes.
|
||||||
|
$effect(() => {
|
||||||
|
if (onCooldown && status?.cooldownEndsAt && !countdownId) {
|
||||||
|
updateCountdown();
|
||||||
|
countdownId = window.setInterval(updateCountdown, 1000);
|
||||||
|
} else if (!onCooldown && countdownId) {
|
||||||
|
clearInterval(countdownId);
|
||||||
|
countdownId = null;
|
||||||
|
countdownText = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// (Re)load status + structure when the user logs in.
|
||||||
|
$effect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loadStatus();
|
||||||
|
loadStructure();
|
||||||
|
} else {
|
||||||
|
status = null;
|
||||||
|
structure = null;
|
||||||
|
expanded = false;
|
||||||
|
submitResult = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (countdownId) clearInterval(countdownId);
|
||||||
|
if (previewTimer) clearTimeout(previewTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="signin-prompt submit-verse-wrap">
|
||||||
|
{#if submitResult}
|
||||||
|
<!-- ── Confirmation state (replaces the panel after success) ──────── -->
|
||||||
|
<div class="confirm-card" in:fly={{ y: -8, duration: 220 }}>
|
||||||
|
<p class="confirm-title">
|
||||||
|
✅ Your verse is scheduled for
|
||||||
|
<strong>{fmtDateVague(submitResult.scheduledDate)}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="confirm-ref">{submitResult.reference}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="submit-another-btn"
|
||||||
|
onclick={() => {
|
||||||
|
submitResult = null;
|
||||||
|
expanded = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if !isLoggedIn}
|
||||||
|
<!-- ── State 1: logged out — sign-in dropdown ─────────────────────── -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="progress-btn w-full"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
data-umami-event="Submit a verse (logged out)"
|
||||||
|
>
|
||||||
|
<span>✨ Submit a verse</span>
|
||||||
|
<svg
|
||||||
|
class="progress-chev"
|
||||||
|
class:open={expanded}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if expanded}
|
||||||
|
<p class="signin-text text-gray-800 dark:text-gray-300">
|
||||||
|
Sign in to submit a verse for a future day
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="/auth/apple" class="w-full">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="apple-signin-btn"
|
||||||
|
data-umami-event="Sign in with Apple"
|
||||||
|
>
|
||||||
|
<svg class="apple-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Apple
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/auth/google" class="w-full">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="google-signin-btn"
|
||||||
|
data-umami-event="Sign in with Google"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="google-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{:else if onCooldown}
|
||||||
|
<!-- ── State 2: logged in, cooldown active — disabled + countdown on button ─── -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="progress-btn w-full progress-btn-disabled"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
<!-- <span>✨ Submit a verse</span> -->
|
||||||
|
{#if countdownText}
|
||||||
|
<span class="countdown-inline" aria-hidden="true"
|
||||||
|
>⏳ {countdownText}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="lock-icon" aria-hidden="true">⏳</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<!-- ── State 3: logged in, can submit — selector + preview ────────── -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="progress-btn w-full"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
data-umami-event="Submit a verse (logged in)"
|
||||||
|
>
|
||||||
|
<span>✨ Submit a verse</span>
|
||||||
|
<svg
|
||||||
|
class="progress-chev"
|
||||||
|
class:open={expanded}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if expanded}
|
||||||
|
<div class="submit-panel" in:fly={{ y: -8, duration: 220 }}>
|
||||||
|
{#if statusError}
|
||||||
|
<p class="panel-error">{statusError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !structure}
|
||||||
|
<p class="panel-hint">Loading Bible structure…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="select-row">
|
||||||
|
<label class="select-label">
|
||||||
|
<span class="select-cap">Book</span>
|
||||||
|
<select
|
||||||
|
class="cascading-select"
|
||||||
|
value={selectedBookId}
|
||||||
|
onchange={(e) => {
|
||||||
|
selectedBookId = e.currentTarget.value;
|
||||||
|
onBookChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<optgroup label="Old Testament">
|
||||||
|
{#each oldBooks as book (book.id)}
|
||||||
|
<option value={book.id}>{book.name}</option>
|
||||||
|
{/each}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="New Testament">
|
||||||
|
{#each newBooks as book (book.id)}
|
||||||
|
<option value={book.id}>{book.name}</option>
|
||||||
|
{/each}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="select-label">
|
||||||
|
<span class="select-cap">Chapter</span>
|
||||||
|
<select
|
||||||
|
class="cascading-select"
|
||||||
|
value={selectedChapter}
|
||||||
|
onchange={(e) => {
|
||||||
|
selectedChapter = Number(e.currentTarget.value);
|
||||||
|
onChapterChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if chapterCount === 0}
|
||||||
|
<option value={0}>—</option>
|
||||||
|
{:else}
|
||||||
|
{#each Array(chapterCount) as _, i (i)}
|
||||||
|
<option value={i + 1}>{i + 1}</option>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="select-label">
|
||||||
|
<span class="select-cap">Verse</span>
|
||||||
|
<select
|
||||||
|
class="cascading-select"
|
||||||
|
value={selectedVerse}
|
||||||
|
onchange={(e) => {
|
||||||
|
selectedVerse = Number(e.currentTarget.value);
|
||||||
|
onVerseChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if verseCount === 0}
|
||||||
|
<option value={0}>—</option>
|
||||||
|
{:else}
|
||||||
|
{#each Array(verseCount) as _, i (i)}
|
||||||
|
<option value={i + 1}>{i + 1}</option>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live 3-verse preview -->
|
||||||
|
<div class="preview-card">
|
||||||
|
{#if previewLoading}
|
||||||
|
<p class="preview-hint">Loading preview…</p>
|
||||||
|
{:else if preview}
|
||||||
|
<p class="preview-ref">{preview.reference}</p>
|
||||||
|
<div class="preview-verses">
|
||||||
|
{#each preview.windowVerses as verseText, i (i)}
|
||||||
|
<p class="preview-verse">{verseText}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="preview-hint">Select a verse to preview.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if submitError}
|
||||||
|
<p class="panel-error">{submitError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="submit-btn"
|
||||||
|
disabled={submitting ||
|
||||||
|
!selectedBookId ||
|
||||||
|
!selectedChapter ||
|
||||||
|
!selectedVerse ||
|
||||||
|
!preview}
|
||||||
|
onclick={handleSubmit}
|
||||||
|
>
|
||||||
|
{submitting ? "Scheduling…" : "Submit verse"}
|
||||||
|
</button>
|
||||||
|
{#if status?.upcoming && status.upcoming.length > 0}
|
||||||
|
<p class="upcoming-text">
|
||||||
|
Your upcoming verse{status.upcoming.length > 1
|
||||||
|
? "s"
|
||||||
|
: ""}:
|
||||||
|
{#each status.upcoming as u, i (i)}
|
||||||
|
{#if i > 0}<span class="upcoming-sep">·</span>{/if}
|
||||||
|
<span class="upcoming-item"
|
||||||
|
>{u.reference} ({fmtDateVague(u.scheduledDate)})</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.submit-verse-wrap {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reuse WinScreen's neobrutalist .progress-btn base (scoped here too). */
|
||||||
|
:global(.submit-verse-wrap .progress-btn) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 6px 6px 0 0 #000;
|
||||||
|
transition:
|
||||||
|
transform 80ms ease,
|
||||||
|
box-shadow 80ms ease;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .progress-btn:hover) {
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
box-shadow: 8px 8px 0 0 #000;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .progress-btn:active) {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: 2px 2px 0 0 #000;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .progress-chev) {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .progress-chev.open) {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:global(.submit-verse-wrap .progress-btn) {
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 6px 6px 0 0 #fff;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .progress-btn:hover) {
|
||||||
|
box-shadow: 8px 8px 0 0 #fff;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .progress-btn:active) {
|
||||||
|
box-shadow: 2px 2px 0 0 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled (cooldown) variant. */
|
||||||
|
:global(.submit-verse-wrap .progress-btn-disabled) {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: 4px 4px 0 0 #000;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .progress-btn-disabled:hover),
|
||||||
|
:global(.submit-verse-wrap .progress-btn-disabled:active) {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 4px 4px 0 0 #000;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:global(.submit-verse-wrap .progress-btn-disabled) {
|
||||||
|
box-shadow: 4px 4px 0 0 #fff;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .progress-btn-disabled:hover),
|
||||||
|
:global(.submit-verse-wrap .progress-btn-disabled:active) {
|
||||||
|
box-shadow: 4px 4px 0 0 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-inline {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Apple / Google sign-in buttons (mirror WinScreen) ── */
|
||||||
|
:global(.submit-verse-wrap .apple-signin-btn),
|
||||||
|
:global(.submit-verse-wrap .google-signin-btn) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 150ms ease,
|
||||||
|
transform 80ms ease;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .apple-signin-btn) {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .apple-signin-btn:hover),
|
||||||
|
:global(.submit-verse-wrap .google-signin-btn:hover) {
|
||||||
|
background: #222;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .apple-signin-btn:active),
|
||||||
|
:global(.submit-verse-wrap .google-signin-btn:active) {
|
||||||
|
background: #111;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:global(.submit-verse-wrap .apple-signin-btn),
|
||||||
|
:global(.submit-verse-wrap .google-signin-btn) {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .apple-signin-btn:hover),
|
||||||
|
:global(.submit-verse-wrap .google-signin-btn:hover) {
|
||||||
|
background: #e5e5e5;
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .apple-signin-btn:active),
|
||||||
|
:global(.submit-verse-wrap .google-signin-btn:active) {
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:global(.submit-verse-wrap .apple-icon),
|
||||||
|
:global(.submit-verse-wrap .google-icon) {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Submit panel ── */
|
||||||
|
.submit-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: oklch(94% 0.028 298.626);
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 6px 6px 0 0 #000;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.submit-panel {
|
||||||
|
background: oklch(22% 0.025 298.626);
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 6px 6px 0 0 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-hint,
|
||||||
|
.preview-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.panel-hint,
|
||||||
|
.preview-hint {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #b91c1c;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.panel-error {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-cap {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.select-cap {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cascading-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.cascading-select {
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preview card ── */
|
||||||
|
.preview-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.preview-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-ref {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.preview-ref {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-verses {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-verse {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #4b5563;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.preview-verse {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Submit button (neobrutalist) ── */
|
||||||
|
.submit-btn {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 4px 4px 0 0 #000;
|
||||||
|
transition:
|
||||||
|
transform 80ms ease,
|
||||||
|
box-shadow 80ms ease,
|
||||||
|
background-color 120ms ease;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
box-shadow: 6px 6px 0 0 #000;
|
||||||
|
background: #15803d;
|
||||||
|
}
|
||||||
|
.submit-btn:active:not(:disabled) {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: 2px 2px 0 0 #000;
|
||||||
|
}
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.submit-btn {
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 4px 4px 0 0 #fff;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
box-shadow: 6px 6px 0 0 #fff;
|
||||||
|
}
|
||||||
|
.submit-btn:active:not(:disabled) {
|
||||||
|
box-shadow: 2px 2px 0 0 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Upcoming list ── */
|
||||||
|
.upcoming-text {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.upcoming-text {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.upcoming-item {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.upcoming-sep {
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Confirmation card ── */
|
||||||
|
.confirm-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.1rem;
|
||||||
|
background: oklch(94% 0.028 298.626);
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 6px 6px 0 0 #000;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.confirm-card {
|
||||||
|
background: oklch(22% 0.025 298.626);
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 6px 6px 0 0 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.confirm-title {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-ref {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.confirm-ref {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-another-btn {
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 3px 3px 0 0 #000;
|
||||||
|
transition:
|
||||||
|
transform 80ms ease,
|
||||||
|
box-shadow 80ms ease;
|
||||||
|
}
|
||||||
|
.submit-another-btn:hover {
|
||||||
|
transform: translate(-1px, -1px);
|
||||||
|
box-shadow: 4px 4px 0 0 #000;
|
||||||
|
}
|
||||||
|
.submit-another-btn:active {
|
||||||
|
transform: translate(1px, 1px);
|
||||||
|
box-shadow: 2px 2px 0 0 #000;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.submit-another-btn {
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 3px 3px 0 0 #fff;
|
||||||
|
}
|
||||||
|
.submit-another-btn:hover {
|
||||||
|
box-shadow: 4px 4px 0 0 #fff;
|
||||||
|
}
|
||||||
|
.submit-another-btn:active {
|
||||||
|
box-shadow: 2px 2px 0 0 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let isDarkMode = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const stored = localStorage.getItem('bibdle-theme');
|
||||||
|
if (stored === 'dark') {
|
||||||
|
isDarkMode = true;
|
||||||
|
} else if (stored === 'light') {
|
||||||
|
isDarkMode = false;
|
||||||
|
} else {
|
||||||
|
isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isDarkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.classList.remove('light');
|
||||||
|
localStorage.setItem('bibdle-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.documentElement.classList.add('light');
|
||||||
|
localStorage.setItem('bibdle-theme', 'light');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
isDarkMode = !isDarkMode;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if browser}
|
||||||
|
<button
|
||||||
|
onclick={toggleTheme}
|
||||||
|
aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
class="flex items-center gap-2 p-1 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
{#if isDarkMode}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4"/>
|
||||||
|
<path d="M12 2v2"/>
|
||||||
|
<path d="M12 20v2"/>
|
||||||
|
<path d="m4.93 4.93 1.41 1.41"/>
|
||||||
|
<path d="m17.66 17.66 1.41 1.41"/>
|
||||||
|
<path d="M2 12h2"/>
|
||||||
|
<path d="M20 12h2"/>
|
||||||
|
<path d="m6.34 17.66-1.41 1.41"/>
|
||||||
|
<path d="m19.07 4.93-1.41 1.41"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs uppercase tracking-widest">
|
||||||
|
{isDarkMode ? 'Light mode' : 'Dark mode'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -1,38 +1,93 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
import { browser } from "$app/environment";
|
||||||
import Container from "./Container.svelte";
|
import { fade } from "svelte/transition";
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
let {
|
interface VerseDisplayData {
|
||||||
data,
|
dailyVerse: {
|
||||||
isWon,
|
date: string;
|
||||||
blurChapter = false,
|
reference: string;
|
||||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
verseText: string;
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
};
|
||||||
let displayReference = $derived(
|
}
|
||||||
blurChapter
|
|
||||||
? dailyVerse.reference
|
let {
|
||||||
.replace(/^Psalms /, "Psalm ")
|
data,
|
||||||
.replace(/\s(\d+):/, " ?:")
|
isWon,
|
||||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
blurChapter = false,
|
||||||
);
|
}: { data: VerseDisplayData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||||
let displayVerseText = $derived(
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
dailyVerse.verseText
|
let displayReference = $derived(
|
||||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
blurChapter
|
||||||
.replace(/[,:;-—]$/, "...")
|
? dailyVerse.reference
|
||||||
);
|
.replace(/^Psalms /, "Psalm ")
|
||||||
|
.replace(/\s(\d+):/, " ?:")
|
||||||
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||||
|
);
|
||||||
|
let displayVerseText = $derived(
|
||||||
|
dailyVerse.verseText
|
||||||
|
.replace(/^([a-z])/, (c: string) => c.toUpperCase())
|
||||||
|
.replace(/[,:;-—]$/, "..."),
|
||||||
|
);
|
||||||
|
|
||||||
|
let showReference = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
// Delay showing reference until GuessesTable animation completes
|
||||||
|
$effect(() => {
|
||||||
|
if (!isWon) {
|
||||||
|
showReference = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already won today (page reload case)
|
||||||
|
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||||
|
const alreadyWonToday =
|
||||||
|
browser && localStorage.getItem(winTrackedKey) === "true";
|
||||||
|
|
||||||
|
if (alreadyWonToday) {
|
||||||
|
// User already won and is refreshing - show immediately
|
||||||
|
showReference = true;
|
||||||
|
} else {
|
||||||
|
// User just won this session - delay for animation
|
||||||
|
const animationDelay = 1800;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
showReference = true;
|
||||||
|
}, animationDelay);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyVerse() {
|
||||||
|
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
<Container
|
||||||
<blockquote
|
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
>
|
||||||
>
|
<blockquote
|
||||||
{displayVerseText}
|
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
|
||||||
</blockquote>
|
>
|
||||||
{#if isWon}
|
{displayVerseText}
|
||||||
<p
|
</blockquote>
|
||||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
<div
|
||||||
>
|
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||||
{displayReference}
|
style="max-height: {showReference ? '200px' : '0px'};"
|
||||||
</p>
|
>
|
||||||
{/if}
|
{#if showReference}
|
||||||
|
<p
|
||||||
|
transition:fade={{ duration: 400 }}
|
||||||
|
class="text-center text-lg! big-text text-green-600! dark:text-green-400! font-bold mt-8 bg-white/70 dark:bg-black/50 rounded-xl px-4 py-2"
|
||||||
|
>
|
||||||
|
{displayReference}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||||
import {
|
import {
|
||||||
getBookById,
|
getVerseSnippet,
|
||||||
toOrdinal,
|
shareResult,
|
||||||
getNextGradeMessage,
|
copyToClipboard as clipboardCopy,
|
||||||
} from "$lib/utils/game";
|
} from "$lib/utils/share";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
import CountdownTimer from "./CountdownTimer.svelte";
|
import CountdownTimer from "./CountdownTimer.svelte";
|
||||||
|
import StreakCounter from "./StreakCounter.svelte";
|
||||||
import ChapterGuess from "./ChapterGuess.svelte";
|
import ChapterGuess from "./ChapterGuess.svelte";
|
||||||
|
import SubmitVerse from "./SubmitVerse.svelte";
|
||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
solveRank: number;
|
solveRank: number;
|
||||||
@@ -25,7 +27,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
grade,
|
|
||||||
statsData,
|
statsData,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
handleShare,
|
handleShare,
|
||||||
@@ -35,6 +36,30 @@
|
|||||||
guessCount,
|
guessCount,
|
||||||
reference,
|
reference,
|
||||||
onChapterGuessCompleted,
|
onChapterGuessCompleted,
|
||||||
|
shareText,
|
||||||
|
verseText,
|
||||||
|
streak = 0,
|
||||||
|
streakPercentile = null,
|
||||||
|
isLoggedIn = false,
|
||||||
|
anonymousId = "",
|
||||||
|
localDate = "",
|
||||||
|
}: {
|
||||||
|
statsData: StatsData | null;
|
||||||
|
correctBookId: string;
|
||||||
|
handleShare: () => void;
|
||||||
|
copyToClipboard: () => void;
|
||||||
|
copied: boolean;
|
||||||
|
statsSubmitted: boolean;
|
||||||
|
guessCount: number;
|
||||||
|
reference: string;
|
||||||
|
onChapterGuessCompleted: () => void;
|
||||||
|
shareText: string;
|
||||||
|
verseText: string;
|
||||||
|
streak?: number;
|
||||||
|
streakPercentile?: number | null;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
|
anonymousId?: string;
|
||||||
|
localDate?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -42,6 +67,25 @@
|
|||||||
typeof navigator !== "undefined" && "share" in navigator,
|
typeof navigator !== "undefined" && "share" in navigator,
|
||||||
);
|
);
|
||||||
let copySuccess = $state(false);
|
let copySuccess = $state(false);
|
||||||
|
let bubbleCopied = $state(false);
|
||||||
|
let copyTracked = $state(false);
|
||||||
|
let showSnippetOption = $state(false);
|
||||||
|
let includeSnippet = $state(false);
|
||||||
|
let showProgressDropdown = $state(false);
|
||||||
|
|
||||||
|
let effectiveShareText = $derived(
|
||||||
|
includeSnippet
|
||||||
|
? (() => {
|
||||||
|
const snippet = getVerseSnippet(verseText);
|
||||||
|
const lines = shareText.split("\n");
|
||||||
|
return [
|
||||||
|
...lines.slice(0, -1),
|
||||||
|
snippet,
|
||||||
|
lines[lines.length - 1],
|
||||||
|
].join("\n");
|
||||||
|
})()
|
||||||
|
: shareText,
|
||||||
|
);
|
||||||
|
|
||||||
// List of congratulations messages with weights
|
// List of congratulations messages with weights
|
||||||
const congratulationsMessages: WeightedMessage[] = [
|
const congratulationsMessages: WeightedMessage[] = [
|
||||||
@@ -57,9 +101,9 @@
|
|||||||
if (guessCount === 1) {
|
if (guessCount === 1) {
|
||||||
const n = Math.random();
|
const n = Math.random();
|
||||||
if (n < 0.99) {
|
if (n < 0.99) {
|
||||||
return "🌟 First try! 🌟";
|
return "First try!";
|
||||||
} else {
|
} else {
|
||||||
return "🗣️ Axios! 🗣️";
|
return "Axios!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,66 +130,27 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
|
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
|
||||||
>
|
>
|
||||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
|
<div class="flex flex-col gap-3">
|
||||||
{congratulationsMessage} The verse is from
|
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
{congratulationsMessage} The verse is from
|
||||||
</p>
|
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||||
<p class="text-lg sm:text-xl md:text-2xl mt-4">
|
>{bookName}</span
|
||||||
You guessed correctly after {guessCount}
|
>.
|
||||||
{guessCount === 1 ? "guess" : "guesses"}.
|
|
||||||
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
|
|
||||||
>{grade}</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex justify-center mt-6">
|
|
||||||
{#if hasWebShare}
|
|
||||||
<!-- mobile and arc in production -->
|
|
||||||
<button
|
|
||||||
onclick={handleShare}
|
|
||||||
data-umami-event="Share"
|
|
||||||
class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
|
|
||||||
>
|
|
||||||
📤 Share
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
copyToClipboard();
|
|
||||||
copySuccess = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
copySuccess = false;
|
|
||||||
}, 3000);
|
|
||||||
}}
|
|
||||||
data-umami-event="Copy to Clipboard"
|
|
||||||
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
|
|
||||||
copySuccess
|
|
||||||
? "bg-white/30"
|
|
||||||
: "bg-white/70 hover:bg-white/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{copySuccess ? "✅ Copied!" : "📋 Copy"}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<!-- dev mode and desktop browsers -->
|
|
||||||
<button
|
|
||||||
onclick={handleShare}
|
|
||||||
data-umami-event="Copy to Clipboard"
|
|
||||||
class={`text-2xl font-bold p-4 ${
|
|
||||||
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
|
|
||||||
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
|
||||||
>
|
|
||||||
{copied ? "✅ Copied!" : "📋 Share"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if guessCount !== 1}
|
|
||||||
<p class="pt-6 big-text text-gray-700!">
|
|
||||||
{getNextGradeMessage(guessCount)}
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
<p class="text-lg sm:text-xl md:text-2xl">
|
||||||
|
You guessed correctly after {guessCount}
|
||||||
|
{guessCount === 1 ? "guess" : "guesses"}.
|
||||||
|
</p>
|
||||||
|
<!-- {#if streak >= 7}
|
||||||
|
<p
|
||||||
|
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||||
|
>
|
||||||
|
Thank you for making BIBDLE part of your daily routine!
|
||||||
|
</p>
|
||||||
|
{/if} -->
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<!-- S++ Bonus Challenge for first try -->
|
<!-- S++ Bonus Challenge for first try -->
|
||||||
@@ -157,12 +162,21 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CountdownTimer />
|
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||||
|
<div class="flex-2 min-w-0 flex flex-col">
|
||||||
|
<CountdownTimer />
|
||||||
|
</div>
|
||||||
|
{#if streak > 0}
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
|
<StreakCounter {streak} {streakPercentile} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Display -->
|
<!-- Statistics Display -->
|
||||||
{#if statsData}
|
{#if statsData}
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||||
@@ -171,7 +185,7 @@
|
|||||||
<!-- Solve Rank Column -->
|
<!-- Solve Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
#{statsData.solveRank}
|
#{statsData.solveRank}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +198,7 @@
|
|||||||
<!-- Guess Rank Column -->
|
<!-- Guess Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
{toOrdinal(statsData.guessRank)}
|
{toOrdinal(statsData.guessRank)}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +220,7 @@
|
|||||||
<!-- Average Column -->
|
<!-- Average Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||||
>
|
>
|
||||||
{statsData.averageGuesses}
|
{statsData.averageGuesses}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,11 +234,236 @@
|
|||||||
</Container>
|
</Container>
|
||||||
{:else if !statsSubmitted}
|
{:else if !statsSubmitted}
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||||
>
|
>
|
||||||
<div class="text-sm opacity-80">Submitting stats...</div>
|
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||||
</Container>
|
</Container>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||||
|
<div class="big-text font-black! text-center text-gray-300!">
|
||||||
|
Share your result
|
||||||
|
</div>
|
||||||
|
<div class="chat-window">
|
||||||
|
<!-- Received bubble: primary action (share / copy) -->
|
||||||
|
<div class="bubble-wrapper received-wrapper">
|
||||||
|
<button
|
||||||
|
class="bubble bubble-received"
|
||||||
|
class:success={copySuccess}
|
||||||
|
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
||||||
|
data-umami-event={hasWebShare
|
||||||
|
? "Share"
|
||||||
|
: "Copy to Clipboard"}
|
||||||
|
onclick={() => {
|
||||||
|
if (hasWebShare) {
|
||||||
|
shareResult(effectiveShareText);
|
||||||
|
} else {
|
||||||
|
if (!copyTracked) {
|
||||||
|
copyTracked = true;
|
||||||
|
}
|
||||||
|
clipboardCopy(effectiveShareText);
|
||||||
|
copySuccess = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copySuccess = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if hasWebShare}
|
||||||
|
📤 Tap here to share
|
||||||
|
{:else if copySuccess}
|
||||||
|
✅ Copied!
|
||||||
|
{:else}
|
||||||
|
📋 Copy to clipboard
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sent bubble: share text preview -->
|
||||||
|
<div class="bubble-wrapper">
|
||||||
|
<button
|
||||||
|
class="bubble bubble-sent"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
data-umami-event="Copy to Clipboard"
|
||||||
|
onclick={() => {
|
||||||
|
if (!copyTracked) {
|
||||||
|
copyTracked = true;
|
||||||
|
}
|
||||||
|
clipboardCopy(effectiveShareText);
|
||||||
|
showSnippetOption = true;
|
||||||
|
bubbleCopied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
bubbleCopied = false;
|
||||||
|
}, 2000);
|
||||||
|
}}>{effectiveShareText}</button
|
||||||
|
>
|
||||||
|
{#if hasWebShare}
|
||||||
|
<span class="copy-hint"
|
||||||
|
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="copy-hint"
|
||||||
|
>{bubbleCopied ? "copied!" : ""}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if streak >= 7}
|
||||||
|
<div class="big-text tracking-widest! font-black! text-center mt-4">
|
||||||
|
Thank you for making Bibdle part of your daily routine! —George
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showSnippetOption}
|
||||||
|
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
||||||
|
<span class="snippet-label">Show verse snippet in share?</span>
|
||||||
|
<button
|
||||||
|
class="snippet-toggle"
|
||||||
|
class:on={includeSnippet}
|
||||||
|
onclick={() => (includeSnippet = !includeSnippet)}
|
||||||
|
aria-pressed={includeSnippet}
|
||||||
|
aria-label="Show snippet in share"
|
||||||
|
>
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoggedIn}
|
||||||
|
<div class="signin-prompt">
|
||||||
|
<a
|
||||||
|
href="/progress"
|
||||||
|
class="progress-btn w-full"
|
||||||
|
data-umami-event="See your progress (logged in)"
|
||||||
|
>
|
||||||
|
<span>📈 See your progress</span>
|
||||||
|
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="signin-prompt">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="progress-btn w-full"
|
||||||
|
aria-expanded={showProgressDropdown}
|
||||||
|
onclick={() => (showProgressDropdown = !showProgressDropdown)}
|
||||||
|
data-umami-event="See your progress (logged out)"
|
||||||
|
>
|
||||||
|
<span>📈 See your progress</span>
|
||||||
|
<svg class="progress-chev" class:open={showProgressDropdown} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if showProgressDropdown}
|
||||||
|
<p class="signin-text text-gray-800 dark:text-gray-300">
|
||||||
|
Create an account (or sign in) to track your progress
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="/auth/apple" class="w-full">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="apple-signin-btn"
|
||||||
|
data-umami-event="Sign in with Apple"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="apple-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Apple
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/auth/google" class="w-full">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="google-signin-btn"
|
||||||
|
data-umami-event="Sign in with Google"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="google-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<SubmitVerse
|
||||||
|
{isLoggedIn}
|
||||||
|
{anonymousId}
|
||||||
|
{localDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="signin-prompt">
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/yWQXbGK8SD"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="discord-btn w-full"
|
||||||
|
data-umami-event="Join the BIBDLE Discord"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="discord-icon"
|
||||||
|
viewBox="0 0 127.14 96.36"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Join the BIBDLE Discord!</span>
|
||||||
|
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signin-prompt">
|
||||||
|
<a
|
||||||
|
href="https://donate.stripe.com/9B6aEZ9WgeZF3qYfF987K00"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="progress-btn w-full"
|
||||||
|
data-umami-event="Support bibdle's development"
|
||||||
|
>
|
||||||
|
<span>❤️ Support bibdle's development</span>
|
||||||
|
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="big-text font-black! text-center text-gray-300! mt-2">
|
||||||
|
BIBDLE will be ad-free forever.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -239,7 +478,489 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
:global(.fade-in) {
|
||||||
animation: fadeIn 0.5s ease-out;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Share card ── */
|
||||||
|
.share-card {
|
||||||
|
background: oklch(94% 0.028 298.626);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.share-card {
|
||||||
|
background: oklch(22% 0.025 298.626);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.04;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat window ── */
|
||||||
|
.chat-window {
|
||||||
|
--sent-color: #0b93f6;
|
||||||
|
--received-color: #3a3a3c;
|
||||||
|
--bg: oklch(94% 0.028 298.626);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 0.5rem 0;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.chat-window {
|
||||||
|
--bg: oklch(22% 0.025 298.626);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bubble wrappers ── */
|
||||||
|
.bubble-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-wrapper {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared bubble base ── */
|
||||||
|
.bubble {
|
||||||
|
position: relative;
|
||||||
|
max-width: 255px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 10px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-wrap: break-word;
|
||||||
|
border-radius: 25px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
filter 80ms ease,
|
||||||
|
transform 80ms ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sent bubble (share text preview) ── */
|
||||||
|
.bubble-sent {
|
||||||
|
color: white;
|
||||||
|
background: var(--sent-color);
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:hover {
|
||||||
|
background-color: #2ea8ff;
|
||||||
|
transform: rotate(-2deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:hover::before {
|
||||||
|
background-color: #2ea8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:active {
|
||||||
|
background-color: #0878d4;
|
||||||
|
transform: rotate(-2deg) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:active::before {
|
||||||
|
background-color: #0878d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sent tail: bottom-right */
|
||||||
|
.bubble-sent::before,
|
||||||
|
.bubble-sent::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
height: 25px;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent::before {
|
||||||
|
width: 20px;
|
||||||
|
right: -7px;
|
||||||
|
background-color: var(--sent-color);
|
||||||
|
border-bottom-left-radius: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent::after {
|
||||||
|
width: 26px;
|
||||||
|
right: -26px;
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Received bubble (action button) ── */
|
||||||
|
.bubble-received {
|
||||||
|
color: #f5f5f7;
|
||||||
|
background: var(--received-color);
|
||||||
|
transform: rotate(2deg);
|
||||||
|
padding: 14px 24px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 14rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:hover {
|
||||||
|
background-color: #4a4a4e;
|
||||||
|
transform: rotate(2deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:hover::before {
|
||||||
|
background-color: #4a4a4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:active {
|
||||||
|
background-color: #2a2a2c;
|
||||||
|
transform: rotate(2deg) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:active::before {
|
||||||
|
background-color: #2a2a2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received.success {
|
||||||
|
background: #c7f7d4;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Received tail: bottom-left (mirror of sent) */
|
||||||
|
.bubble-received::before,
|
||||||
|
.bubble-received::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
height: 25px;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received::before {
|
||||||
|
width: 20px;
|
||||||
|
left: -7px;
|
||||||
|
background-color: var(--received-color);
|
||||||
|
border-bottom-right-radius: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received::after {
|
||||||
|
width: 26px;
|
||||||
|
left: -26px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received.success::before {
|
||||||
|
background-color: #c7f7d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Copy hints ── */
|
||||||
|
.copy-hint {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #444;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
padding-right: 32px;
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
transform-origin: right center;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.copy-hint {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Snippet toggle row ── */
|
||||||
|
.snippet-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #666;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.snippet-label {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ccc;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 200ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-toggle.on {
|
||||||
|
background: #34c759;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-toggle.on .toggle-thumb {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── See your progress button (neobrutalist) ── */
|
||||||
|
.progress-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 6px 6px 0 0 #000;
|
||||||
|
transition:
|
||||||
|
transform 80ms ease,
|
||||||
|
box-shadow 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-btn:hover {
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
box-shadow: 8px 8px 0 0 #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-btn:active {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: 2px 2px 0 0 #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-chev {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-chev.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.progress-btn {
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 6px 6px 0 0 #fff;
|
||||||
|
}
|
||||||
|
.progress-btn:hover {
|
||||||
|
box-shadow: 8px 8px 0 0 #fff;
|
||||||
|
}
|
||||||
|
.progress-btn:active {
|
||||||
|
box-shadow: 2px 2px 0 0 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-prompt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
/*padding: 1rem 0 0.25rem;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Discord button (neobrutalist, discord purple) ── */
|
||||||
|
.discord-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
background: #5865f2;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #000;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 6px 6px 0 0 #000;
|
||||||
|
transition:
|
||||||
|
transform 80ms ease,
|
||||||
|
box-shadow 80ms ease,
|
||||||
|
background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-btn:hover {
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
box-shadow: 8px 8px 0 0 #000;
|
||||||
|
background: #4752c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-btn:active {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: 2px 2px 0 0 #000;
|
||||||
|
background: #3c45a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.discord-btn {
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 6px 6px 0 0 #fff;
|
||||||
|
}
|
||||||
|
.discord-btn:hover {
|
||||||
|
box-shadow: 8px 8px 0 0 #fff;
|
||||||
|
}
|
||||||
|
.discord-btn:active {
|
||||||
|
box-shadow: 2px 2px 0 0 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-signin-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 150ms ease,
|
||||||
|
transform 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-signin-btn:hover {
|
||||||
|
background: #222;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-signin-btn:active {
|
||||||
|
background: #111;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.apple-signin-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.apple-signin-btn:hover {
|
||||||
|
background: #e5e5e5;
|
||||||
|
}
|
||||||
|
.apple-signin-btn:active {
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-signin-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 150ms ease,
|
||||||
|
transform 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-signin-btn:hover {
|
||||||
|
background: #222;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-signin-btn:active {
|
||||||
|
background: #111;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.google-signin-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.google-signin-btn:hover {
|
||||||
|
background: #e5e5e5;
|
||||||
|
}
|
||||||
|
.google-signin-btn:active {
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
|
||||||
export * from './utils/game';
|
export * from './utils/game';
|
||||||
export { default as VerseDisplay } from './components/VerseDisplay.svelte';
|
|
||||||
export { default as SearchInput } from './components/SearchInput.svelte';
|
|
||||||
export { default as GuessesTable } from './components/GuessesTable.svelte';
|
|
||||||
export { default as WinScreen } from './components/WinScreen.svelte';
|
|
||||||
export { default as Feedback } from './components/Feedback.svelte';
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||||
|
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||||
|
|
||||||
|
export function getAppleAuthUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.APPLE_ID!,
|
||||||
|
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/apple/callback`,
|
||||||
|
response_type: 'code',
|
||||||
|
response_mode: 'form_post',
|
||||||
|
scope: 'name email',
|
||||||
|
state
|
||||||
|
});
|
||||||
|
return `${APPLE_AUTH_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAppleClientSecret(): Promise<string> {
|
||||||
|
const header = { alg: 'ES256', kid: Bun.env.APPLE_KEY_ID! };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = {
|
||||||
|
iss: Bun.env.APPLE_TEAM_ID!,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 86400 * 180,
|
||||||
|
aud: 'https://appleid.apple.com',
|
||||||
|
sub: Bun.env.APPLE_ID!
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||||
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
|
||||||
|
// Import PEM private key
|
||||||
|
const pemBody = Bun.env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||||
|
.replace(/-----END PRIVATE KEY-----/, '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
keyBuffer,
|
||||||
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signatureBuffer = await crypto.subtle.sign(
|
||||||
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(signingInput)
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = new Uint8Array(signatureBuffer);
|
||||||
|
|
||||||
|
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||||
|
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||||
|
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||||
|
const encodedSignature = Buffer.from(rawSignature).toString('base64url');
|
||||||
|
|
||||||
|
return `${signingInput}.${encodedSignature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
|
||||||
|
*/
|
||||||
|
function derToRaw(der: Uint8Array): Uint8Array {
|
||||||
|
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
|
||||||
|
let offset = 2; // skip 0x30 and total length
|
||||||
|
|
||||||
|
// Read r
|
||||||
|
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||||
|
offset++;
|
||||||
|
const rLen = der[offset];
|
||||||
|
offset++;
|
||||||
|
let r = der.slice(offset, offset + rLen);
|
||||||
|
offset += rLen;
|
||||||
|
|
||||||
|
// Read s
|
||||||
|
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||||
|
offset++;
|
||||||
|
const sLen = der[offset];
|
||||||
|
offset++;
|
||||||
|
let s = der.slice(offset, offset + sLen);
|
||||||
|
|
||||||
|
// Remove leading zero padding (DER uses it for positive sign)
|
||||||
|
if (r.length === 33 && r[0] === 0) r = r.slice(1);
|
||||||
|
if (s.length === 33 && s[0] === 0) s = s.slice(1);
|
||||||
|
|
||||||
|
// Pad to 32 bytes each
|
||||||
|
const raw = new Uint8Array(64);
|
||||||
|
raw.set(r, 32 - r.length);
|
||||||
|
raw.set(s, 64 - s.length);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeAppleCode(
|
||||||
|
code: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
id_token: string;
|
||||||
|
}> {
|
||||||
|
const clientSecret = await generateAppleClientSecret();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.APPLE_ID!,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(APPLE_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Apple token exchange failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Apple's id_token JWT payload without signature verification.
|
||||||
|
* Safe because the token is received directly from Apple's token endpoint over TLS.
|
||||||
|
*/
|
||||||
|
export function decodeAppleIdToken(idToken: string): {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: string;
|
||||||
|
is_private_email?: string;
|
||||||
|
} {
|
||||||
|
const [, payloadB64] = idToken.split('.');
|
||||||
|
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||||
|
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { testDb as db } from '$lib/server/db/test';
|
||||||
|
import * as table from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
|
export const sessionCookieName = 'auth-session';
|
||||||
|
|
||||||
|
export function generateSessionToken() {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
|
return Buffer.from(bytes).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(token: string, userId: string) {
|
||||||
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
|
const session: table.Session = {
|
||||||
|
id: sessionId,
|
||||||
|
userId,
|
||||||
|
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
|
||||||
|
};
|
||||||
|
await db.insert(table.session).values(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateSessionToken(token: string) {
|
||||||
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
|
const [result] = await db
|
||||||
|
.select({
|
||||||
|
// Adjust user table here to tweak returned data
|
||||||
|
user: { id: table.user.id, email: table.user.email },
|
||||||
|
session: table.session
|
||||||
|
})
|
||||||
|
.from(table.session)
|
||||||
|
.innerJoin(table.user, eq(table.session.userId, table.user.id))
|
||||||
|
.where(eq(table.session.id, sessionId));
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return { session: null, user: null };
|
||||||
|
}
|
||||||
|
const { session, user } = result;
|
||||||
|
|
||||||
|
const sessionExpired = Date.now() >= session.expiresAt.getTime();
|
||||||
|
if (sessionExpired) {
|
||||||
|
await db.delete(table.session).where(eq(table.session.id, session.id));
|
||||||
|
return { session: null, user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
|
||||||
|
if (renewSession) {
|
||||||
|
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
|
||||||
|
await db
|
||||||
|
.update(table.session)
|
||||||
|
.set({ expiresAt: session.expiresAt })
|
||||||
|
.where(eq(table.session.id, session.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
|
||||||
|
|
||||||
|
export async function invalidateSession(sessionId: string) {
|
||||||
|
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
|
||||||
|
cookies.set(sessionCookieName, token, {
|
||||||
|
expires: expiresAt,
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
|
||||||
|
cookies.delete(sessionCookieName, {
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return await Bun.password.hash(password, {
|
||||||
|
algorithm: 'argon2id',
|
||||||
|
memoryCost: 4,
|
||||||
|
timeCost: 3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await Bun.password.verify(password, hash);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
|
||||||
|
const user: table.User = {
|
||||||
|
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
appleId: null,
|
||||||
|
googleId: null,
|
||||||
|
firstName: firstName || null,
|
||||||
|
lastName: lastName || null,
|
||||||
|
isPrivate: false
|
||||||
|
};
|
||||||
|
await db.insert(table.user).values(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
+95
-12
@@ -1,7 +1,5 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { Cookies, RequestEvent } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sha256 } from '@oslojs/crypto/sha2';
|
|
||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
|
|
||||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
|||||||
|
|
||||||
export function generateSessionToken() {
|
export function generateSessionToken() {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
const token = encodeBase64url(bytes);
|
return Buffer.from(bytes).toString('base64url');
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(token: string, userId: string) {
|
export async function createSession(token: string, userId: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const session: table.Session = {
|
const session: table.Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -27,11 +24,11 @@ export async function createSession(token: string, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(token: string) {
|
export async function validateSessionToken(token: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// Adjust user table here to tweak returned data
|
||||||
user: { id: table.user.id, username: table.user.username },
|
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId, googleId: table.user.googleId },
|
||||||
session: table.session
|
session: table.session
|
||||||
})
|
})
|
||||||
.from(table.session)
|
.from(table.session)
|
||||||
@@ -67,15 +64,101 @@ 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: '/'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return await Bun.password.hash(password, {
|
||||||
|
algorithm: 'argon2id',
|
||||||
|
memoryCost: 4,
|
||||||
|
timeCost: 3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await Bun.password.verify(password, hash);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
|
||||||
|
const user: table.User = {
|
||||||
|
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
appleId: null,
|
||||||
|
googleId: null,
|
||||||
|
firstName: firstName || null,
|
||||||
|
lastName: lastName || null,
|
||||||
|
isPrivate: false
|
||||||
|
};
|
||||||
|
await db.insert(table.user).values(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByAppleId(appleId: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId));
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByGoogleId(googleId: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.googleId, googleId));
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||||
|
if (!anonymousId || anonymousId === userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { dailyCompletions } = await import('$lib/server/db/schema');
|
||||||
|
|
||||||
|
const anonCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||||
|
|
||||||
|
const userCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const userDates = new Set(userCompletions.map((c) => c.date));
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const completion of anonCompletions) {
|
||||||
|
if (!userDates.has(completion.date)) {
|
||||||
|
await db
|
||||||
|
.update(dailyCompletions)
|
||||||
|
.set({ anonymousId: userId })
|
||||||
|
.where(eq(dailyCompletions.id, completion.id));
|
||||||
|
migrated++;
|
||||||
|
} else {
|
||||||
|
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id));
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error migrating anonymous stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyVerses } from '$lib/server/db/schema';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import { fetchRandomVerse, fetchRandomVerseAvoiding } from '$lib/server/bible-api';
|
||||||
|
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> {
|
||||||
|
// Validate date format (YYYY-MM-DD)
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||||
|
throw new Error('Invalid date format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's an existing verse for this date, return it
|
||||||
|
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return existing[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise get a new random verse for this date. Gap-fill is extended to
|
||||||
|
// 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 newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||||
|
id: Bun.randomUUIDv7(),
|
||||||
|
date: dateStr,
|
||||||
|
bookId: apiVerse.bookId,
|
||||||
|
verseText: apiVerse.verseText,
|
||||||
|
reference: apiVerse.reference,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
import Database from 'better-sqlite3';
|
import { Database } from 'bun:sqlite';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
|
|
||||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
if (!Bun.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
const client = new Database(env.DATABASE_URL);
|
const client = new Database(Bun.env.DATABASE_URL);
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
export const user = sqliteTable('user', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
firstName: text('first_name'),
|
||||||
|
lastName: text('last_name'),
|
||||||
|
email: text('email').unique(),
|
||||||
|
passwordHash: text('password_hash'),
|
||||||
|
appleId: text('apple_id').unique(),
|
||||||
|
googleId: text('google_id').unique(),
|
||||||
|
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||||
|
});
|
||||||
|
|
||||||
export const session = sqliteTable('session', {
|
export const session = sqliteTable('session', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -28,11 +37,31 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
|||||||
anonymousId: text('anonymous_id').notNull(),
|
anonymousId: text('anonymous_id').notNull(),
|
||||||
date: text('date').notNull(),
|
date: text('date').notNull(),
|
||||||
guessCount: integer('guess_count').notNull(),
|
guessCount: integer('guess_count').notNull(),
|
||||||
|
guesses: text('guesses'), // nullable; only stored for logged-in users
|
||||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => ({
|
}, (table) => [
|
||||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||||
dateIndex: index('date_idx').on(table.date),
|
index('date_idx').on(table.date),
|
||||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
index('date_guess_idx').on(table.date, table.guessCount),
|
||||||
}));
|
// Ensures schema matches the database migration and prevents duplicate submissions
|
||||||
|
unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
|
||||||
|
]);
|
||||||
|
|
||||||
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,9 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
if (!Bun.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
|
||||||
|
|
||||||
|
const testClient = new Database(Bun.env.TEST_DATABASE_URL);
|
||||||
|
|
||||||
|
export const testDb = drizzle(testClient, { schema });
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||||
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||||
|
|
||||||
|
export function getGoogleAuthUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.GOOGLE_CLIENT_ID!,
|
||||||
|
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
access_type: 'online',
|
||||||
|
prompt: 'select_account'
|
||||||
|
});
|
||||||
|
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeGoogleCode(
|
||||||
|
code: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
id_token: string;
|
||||||
|
scope: string;
|
||||||
|
}> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.GOOGLE_CLIENT_ID!,
|
||||||
|
client_secret: Bun.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Google token exchange failed: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Google's id_token JWT payload without signature verification.
|
||||||
|
* Safe because the token is received directly from Google's token endpoint over TLS.
|
||||||
|
*/
|
||||||
|
export function decodeGoogleIdToken(idToken: string): {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
name?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
} {
|
||||||
|
const [, payloadB64] = idToken.split('.');
|
||||||
|
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||||
|
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
|
import type { DailyCompletion } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export type Milestone = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
description: string;
|
||||||
|
achieved: boolean;
|
||||||
|
achievedDate: string | null; // YYYY-MM-DD of first achievement, or null
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClassicMilestoneInputs = {
|
||||||
|
bestSingleGame: { date: string; bookName: string } | null;
|
||||||
|
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function calculateMilestones(
|
||||||
|
completions: DailyCompletion[],
|
||||||
|
dateToBookId: Map<string, string>,
|
||||||
|
classic: ClassicMilestoneInputs,
|
||||||
|
): Promise<Milestone[]> {
|
||||||
|
const sorted = [...completions].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
// Helper: returns the date when all books in targetIds were first solved
|
||||||
|
function findSetDate(targetIds: Set<string>): string | null {
|
||||||
|
const solved = new Set<string>();
|
||||||
|
for (const c of sorted) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (bookId && targetIds.has(bookId)) {
|
||||||
|
solved.add(bookId);
|
||||||
|
if (solved.size === targetIds.size) return c.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Book sets
|
||||||
|
const ntIds = new Set(bibleBooks.filter(b => b.testament === 'new').map(b => b.id));
|
||||||
|
const otIds = new Set(bibleBooks.filter(b => b.testament === 'old').map(b => b.id));
|
||||||
|
const allIds = new Set(bibleBooks.map(b => b.id));
|
||||||
|
const gospelIds = new Set(['MAT', 'MRK', 'LUK', 'JHN']);
|
||||||
|
const pentateuchIds = new Set(['GEN', 'EXO', 'LEV', 'NUM', 'DEU']);
|
||||||
|
|
||||||
|
// Set-completion milestones
|
||||||
|
const ntScholarDate = findSetDate(ntIds);
|
||||||
|
const otScholarDate = findSetDate(otIds);
|
||||||
|
const theologianDate = findSetDate(allIds);
|
||||||
|
const fantasticFourDate = findSetDate(gospelIds);
|
||||||
|
const pentatonixDate = findSetDate(pentateuchIds);
|
||||||
|
|
||||||
|
// With God, All Things Are Possible — solved in 1 guess for at least one puzzle from each of the 66 books
|
||||||
|
const booksInOne = new Set<string>();
|
||||||
|
let withGodDate: string | null = null;
|
||||||
|
for (const c of sorted) {
|
||||||
|
if (c.guessCount === 1) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (bookId) {
|
||||||
|
booksInOne.add(bookId);
|
||||||
|
if (withGodDate === null && booksInOne.size === allIds.size) {
|
||||||
|
withGodDate = c.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allInOne = booksInOne.size === allIds.size;
|
||||||
|
|
||||||
|
// Is This A Joke To You? — guessed all 65 other books first (66 guesses total)
|
||||||
|
const jokeCompletion = sorted.find(c => c.guessCount >= 66);
|
||||||
|
|
||||||
|
// Prodigal Son — returned after a 30+ day gap
|
||||||
|
let prodigalDate: string | null = null;
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const prev = new Date(sorted[i - 1].date + 'T00:00:00Z');
|
||||||
|
const curr = new Date(sorted[i].date + 'T00:00:00Z');
|
||||||
|
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||||
|
if (diff >= 30) {
|
||||||
|
prodigalDate = sorted[i].date;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra Credit — solved on a Sunday
|
||||||
|
const sundayCompletion = sorted.find(c => {
|
||||||
|
const d = new Date(c.date + 'T00:00:00Z');
|
||||||
|
return d.getUTCDay() === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cross-user milestones: Overachiever, Procrastinator, Outlier
|
||||||
|
let overachieverDate: string | null = null;
|
||||||
|
let procrastinatorDate: string | null = null;
|
||||||
|
let outlierDate: string | null = null;
|
||||||
|
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
const userDates = sorted.map(c => c.date);
|
||||||
|
const allOnDates = await db
|
||||||
|
.select({
|
||||||
|
date: dailyCompletions.date,
|
||||||
|
completedAt: dailyCompletions.completedAt,
|
||||||
|
guessCount: dailyCompletions.guessCount,
|
||||||
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
|
})
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(inArray(dailyCompletions.date, userDates));
|
||||||
|
|
||||||
|
// Group all completions by date
|
||||||
|
const byDate = new Map<string, typeof allOnDates>();
|
||||||
|
for (const c of allOnDates) {
|
||||||
|
const arr = byDate.get(c.date) ?? [];
|
||||||
|
arr.push(c);
|
||||||
|
byDate.set(c.date, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userByDate = new Map(sorted.map(c => [c.date, c]));
|
||||||
|
|
||||||
|
for (const userComp of sorted) {
|
||||||
|
const allForDate = byDate.get(userComp.date) ?? [];
|
||||||
|
if (allForDate.length < 2) continue; // need multiple players
|
||||||
|
|
||||||
|
const validTimes = allForDate
|
||||||
|
.filter(c => c.completedAt != null)
|
||||||
|
.map(c => c.completedAt!.getTime());
|
||||||
|
|
||||||
|
if (!overachieverDate && userComp.completedAt && validTimes.length > 0) {
|
||||||
|
const earliest = Math.min(...validTimes);
|
||||||
|
if (userComp.completedAt.getTime() === earliest) {
|
||||||
|
overachieverDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!procrastinatorDate && userComp.completedAt && validTimes.length > 0) {
|
||||||
|
const latest = Math.max(...validTimes);
|
||||||
|
if (userComp.completedAt.getTime() === latest) {
|
||||||
|
procrastinatorDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outlierDate && allForDate.length >= 10) {
|
||||||
|
const sortedGuesses = allForDate.map(c => c.guessCount).sort((a, b) => a - b);
|
||||||
|
const cutoffIndex = Math.ceil(sortedGuesses.length * 0.1) - 1;
|
||||||
|
const cutoff = sortedGuesses[cutoffIndex];
|
||||||
|
if (userComp.guessCount <= cutoff) {
|
||||||
|
outlierDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'first-1-guess',
|
||||||
|
name: 'Lightning Strike',
|
||||||
|
emoji: '⚡',
|
||||||
|
description: `First 1-guess solve${classic.bestSingleGame ? ` — ${classic.bestSingleGame.bookName}` : ''}`,
|
||||||
|
achieved: classic.bestSingleGame !== null,
|
||||||
|
achievedDate: classic.bestSingleGame?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-7',
|
||||||
|
name: '7-Day Streak',
|
||||||
|
emoji: '🔥',
|
||||||
|
description: 'Solve Bibdle 7 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days7 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-14',
|
||||||
|
name: '14-Day Streak',
|
||||||
|
emoji: '💥',
|
||||||
|
description: 'Solve Bibdle 14 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days14 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-30',
|
||||||
|
name: '30-Day Streak',
|
||||||
|
emoji: '🏅',
|
||||||
|
description: 'Solve Bibdle 30 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days30 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nt-scholar',
|
||||||
|
name: 'NT Scholar',
|
||||||
|
emoji: '✝️',
|
||||||
|
description: 'Solve for every New Testament book',
|
||||||
|
achieved: ntScholarDate !== null,
|
||||||
|
achievedDate: ntScholarDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ot-scholar',
|
||||||
|
name: 'OT Scholar',
|
||||||
|
emoji: '📜',
|
||||||
|
description: 'Solve for every Old Testament book',
|
||||||
|
achieved: otScholarDate !== null,
|
||||||
|
achievedDate: otScholarDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theologian',
|
||||||
|
name: 'Theologian',
|
||||||
|
emoji: '🎓',
|
||||||
|
description: 'Solve for all 66 books of the Bible',
|
||||||
|
achieved: theologianDate !== null,
|
||||||
|
achievedDate: theologianDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fantastic-four',
|
||||||
|
name: 'The Fantastic Four',
|
||||||
|
emoji: '4️⃣',
|
||||||
|
description: 'Solve a puzzle for all four Gospels',
|
||||||
|
achieved: fantasticFourDate !== null,
|
||||||
|
achievedDate: fantasticFourDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pentatonix',
|
||||||
|
name: 'Pentatonix',
|
||||||
|
emoji: '📃',
|
||||||
|
description: 'Solve a puzzle for all five books of the Pentateuch',
|
||||||
|
achieved: pentatonixDate !== null,
|
||||||
|
achievedDate: pentatonixDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'with-god',
|
||||||
|
name: 'With God, All Things Are Possible',
|
||||||
|
emoji: '🙏',
|
||||||
|
description: 'Solve in 1 guess for each of the 66 books at least once',
|
||||||
|
achieved: allInOne,
|
||||||
|
achievedDate: withGodDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'is-this-a-joke',
|
||||||
|
name: 'Is This A Joke To You?',
|
||||||
|
emoji: '😤',
|
||||||
|
description: 'Guess all 65 other books before getting the right one',
|
||||||
|
achieved: jokeCompletion !== undefined,
|
||||||
|
achievedDate: jokeCompletion?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overachiever',
|
||||||
|
name: 'Overachiever',
|
||||||
|
emoji: '⚡',
|
||||||
|
description: 'Be the first person to solve Bibdle on a day',
|
||||||
|
achieved: overachieverDate !== null,
|
||||||
|
achievedDate: overachieverDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'procrastinator',
|
||||||
|
name: 'Procrastinator',
|
||||||
|
emoji: '🐢',
|
||||||
|
description: 'Be the last person to solve Bibdle on a day',
|
||||||
|
achieved: procrastinatorDate !== null,
|
||||||
|
achievedDate: procrastinatorDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prodigal-son',
|
||||||
|
name: 'Prodigal Son',
|
||||||
|
emoji: '🏠',
|
||||||
|
description: 'Return to Bibdle after at least 30 days away',
|
||||||
|
achieved: prodigalDate !== null,
|
||||||
|
achievedDate: prodigalDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'extra-credit',
|
||||||
|
name: 'Extra Credit',
|
||||||
|
emoji: '📅',
|
||||||
|
description: 'Solve Bibdle on a Sunday',
|
||||||
|
achieved: sundayCompletion !== undefined,
|
||||||
|
achievedDate: sundayCompletion?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outlier',
|
||||||
|
name: 'Outlier',
|
||||||
|
emoji: '📊',
|
||||||
|
description: 'Finish in the top 10% of solves on a day (fewest guesses)',
|
||||||
|
achieved: outlierDate !== null,
|
||||||
|
achievedDate: outlierDate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
||||||
|
|
||||||
|
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
|
||||||
|
// Used to attribute stats to a player who hasn't signed in.
|
||||||
|
function getOrCreateAnonymousId(): string {
|
||||||
|
if (!browser) return "";
|
||||||
|
const key = "bibdle-anonymous-id";
|
||||||
|
let id = localStorage.getItem(key);
|
||||||
|
if (!id) {
|
||||||
|
id = generateUUID();
|
||||||
|
localStorage.setItem(key, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive store that keeps in-memory game state in sync with localStorage.
|
||||||
|
// Accepts getter functions (rather than plain values) so Svelte's reactivity
|
||||||
|
// system can track dependencies and re-run effects when they change.
|
||||||
|
type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createGamePersistence(
|
||||||
|
getDate: () => string,
|
||||||
|
getReference: () => string,
|
||||||
|
getCorrectBookId: () => string,
|
||||||
|
getUser: () => AuthUser | null | undefined,
|
||||||
|
) {
|
||||||
|
let guesses = $state<Guess[]>([]);
|
||||||
|
let anonymousId = $state("");
|
||||||
|
let statsSubmitted = $state(false);
|
||||||
|
let chapterGuessCompleted = $state(false);
|
||||||
|
let chapterCorrect = $state(false);
|
||||||
|
|
||||||
|
// On mount (and if the user logs in/out), resolve the player's identity and
|
||||||
|
// restore per-day flags from localStorage.
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const user = getUser();
|
||||||
|
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||||
|
if (user) {
|
||||||
|
anonymousId = user.id;
|
||||||
|
} else {
|
||||||
|
anonymousId = getOrCreateAnonymousId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell analytics which player this is so events are grouped correctly.
|
||||||
|
if ((window as any).umami) {
|
||||||
|
(window as any).umami.identify(anonymousId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const date = getDate();
|
||||||
|
const reference = getReference();
|
||||||
|
|
||||||
|
// Restore whether today's completion was already submitted to the server.
|
||||||
|
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
||||||
|
|
||||||
|
// Restore the chapter bonus guess result. The stored value includes the
|
||||||
|
// chapter the player selected, so we can re-derive whether it was correct.
|
||||||
|
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||||
|
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||||
|
if (chapterGuessCompleted) {
|
||||||
|
const saved = localStorage.getItem(chapterGuessKey);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
const match = reference.match(/\s(\d+):/);
|
||||||
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||||
|
chapterCorrect = data.selectedChapter === correctChapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// On mount (and if the date or correct answer changes), load today's guesses
|
||||||
|
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
|
||||||
|
// each stored book ID against the correct answer.
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const date = getDate();
|
||||||
|
const correctBookId = getCorrectBookId();
|
||||||
|
const key = `bibdle-guesses-${date}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (!saved) {
|
||||||
|
guesses = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let savedIds: string[] = JSON.parse(saved);
|
||||||
|
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
|
||||||
|
guesses = savedIds
|
||||||
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||||
|
.filter((g): g is Guess => g !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist guesses to localStorage whenever they change. Only the book IDs are
|
||||||
|
// stored — the full Guess shape is re-derived on load (see effect above).
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const date = getDate();
|
||||||
|
localStorage.setItem(
|
||||||
|
`bibdle-guesses-${date}`,
|
||||||
|
JSON.stringify(guesses.map((g) => g.book.id)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Called after stats are successfully submitted to the server so that
|
||||||
|
// returning to the page doesn't trigger a duplicate submission.
|
||||||
|
function markStatsSubmitted() {
|
||||||
|
if (!browser) return;
|
||||||
|
statsSubmitted = true;
|
||||||
|
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marks the win as tracked for analytics. Returns true the first time (new
|
||||||
|
// win), false on subsequent calls so the analytics event fires exactly once.
|
||||||
|
function markWinTracked() {
|
||||||
|
if (!browser) return;
|
||||||
|
const key = `bibdle-win-tracked-${getDate()}`;
|
||||||
|
if (localStorage.getItem(key) === "true") return false;
|
||||||
|
localStorage.setItem(key, "true");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the win has already been tracked in a previous render/session.
|
||||||
|
// Used to skip the animation delay when returning to an already-won game.
|
||||||
|
function isWinAlreadyTracked(): boolean {
|
||||||
|
if (!browser) return false;
|
||||||
|
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrites local state with the server's authoritative guess record.
|
||||||
|
// Called when a logged-in user opens the game on a new device so their
|
||||||
|
// progress from another device is restored.
|
||||||
|
function hydrateFromServer(guessIds: string[]) {
|
||||||
|
if (!browser) return;
|
||||||
|
const correctBookId = getCorrectBookId();
|
||||||
|
const date = getDate();
|
||||||
|
guesses = guessIds
|
||||||
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||||
|
.filter((g): g is Guess => g !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by the WinScreen after the player submits their chapter bonus guess.
|
||||||
|
// Reads the result written to localStorage by WinScreen and updates reactive state.
|
||||||
|
function onChapterGuessCompleted() {
|
||||||
|
if (!browser) return;
|
||||||
|
chapterGuessCompleted = true;
|
||||||
|
const reference = getReference();
|
||||||
|
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||||
|
const saved = localStorage.getItem(chapterGuessKey);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
const match = reference.match(/\s(\d+):/);
|
||||||
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||||
|
chapterCorrect = data.selectedChapter === correctChapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get guesses() { return guesses; },
|
||||||
|
set guesses(v: Guess[]) { guesses = v; },
|
||||||
|
get anonymousId() { return anonymousId; },
|
||||||
|
get statsSubmitted() { return statsSubmitted; },
|
||||||
|
get chapterGuessCompleted() { return chapterGuessCompleted; },
|
||||||
|
get chapterCorrect() { return chapterCorrect; },
|
||||||
|
markStatsSubmitted,
|
||||||
|
markWinTracked,
|
||||||
|
isWinAlreadyTracked,
|
||||||
|
onChapterGuessCompleted,
|
||||||
|
hydrateFromServer,
|
||||||
|
};
|
||||||
|
}
|
||||||
+66
-68
@@ -17,75 +17,73 @@ export interface BibleBook {
|
|||||||
testament: Testament;
|
testament: Testament;
|
||||||
section: BibleSection;
|
section: BibleSection;
|
||||||
order: number;
|
order: number;
|
||||||
url: string;
|
|
||||||
popularity: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bibleBooks: BibleBook[] = [
|
export const bibleBooks: BibleBook[] = [
|
||||||
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1, url: 'https://bible-api.com/data/web/GEN', popularity: 8 },
|
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
|
||||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
|
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
|
||||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
|
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
|
||||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
|
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
|
||||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
|
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
|
||||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
|
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
|
||||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
|
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
|
||||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
|
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
|
||||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
|
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
|
||||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
|
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
|
||||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
|
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
|
||||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
|
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
|
||||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
|
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
|
||||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
|
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
|
||||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
|
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
|
||||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
|
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
|
||||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
|
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
|
||||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
|
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
|
||||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
|
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
|
||||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
|
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
|
||||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
|
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
|
||||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
|
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
|
||||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
|
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
|
||||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
|
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
|
||||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
|
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
|
||||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
|
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
|
||||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
|
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
|
||||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
|
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
|
||||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
|
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
|
||||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
|
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
|
||||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
|
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
|
||||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
|
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
|
||||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
|
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
|
||||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
|
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
|
||||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
|
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
|
||||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
|
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
|
||||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
|
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
|
||||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
|
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
|
||||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
|
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
|
||||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
|
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
|
||||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
|
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
|
||||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
|
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
|
||||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
|
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
|
||||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
|
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
|
||||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
|
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
|
||||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
|
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
|
||||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
|
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
|
||||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
|
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
|
||||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
|
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
|
||||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
|
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
|
||||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
|
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
|
||||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
|
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
|
||||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
|
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
|
||||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
|
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
|
||||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
|
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
|
||||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
|
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
|
||||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
|
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
|
||||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
|
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
|
||||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
|
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
|
||||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
|
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
|
||||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
|
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
|
||||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
|
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
|
||||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
|
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
|
||||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
|
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
|
||||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
|
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
|
||||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
|
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66 }
|
||||||
];
|
];
|
||||||
|
|||||||
+50
-2
@@ -1,5 +1,13 @@
|
|||||||
import { bibleBooks, type BibleBook } from '$lib/types/bible';
|
import { bibleBooks, type BibleBook } from '$lib/types/bible';
|
||||||
|
|
||||||
|
export interface Guess {
|
||||||
|
book: BibleBook;
|
||||||
|
testamentMatch: boolean;
|
||||||
|
sectionMatch: boolean;
|
||||||
|
adjacent: boolean;
|
||||||
|
firstLetterMatch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function getBookById(id: string): BibleBook | undefined {
|
export function getBookById(id: string): BibleBook | undefined {
|
||||||
return bibleBooks.find((b) => b.id === id);
|
return bibleBooks.find((b) => b.id === id);
|
||||||
}
|
}
|
||||||
@@ -10,7 +18,47 @@ export function isAdjacent(id1: string, id2: string): boolean {
|
|||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGrade(numGuesses: number, popularity: number): string {
|
export function getFirstLetter(bookName: string): string {
|
||||||
|
const match = bookName.match(/[a-zA-Z]/);
|
||||||
|
return match ? match[0] : bookName[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateGuess(guessBookId: string, correctBookId: string): Guess | null {
|
||||||
|
const book = getBookById(guessBookId);
|
||||||
|
const correctBook = getBookById(correctBookId);
|
||||||
|
if (!book || !correctBook) return null;
|
||||||
|
|
||||||
|
const testamentMatch = book.testament === correctBook.testament;
|
||||||
|
const sectionMatch = book.section === correctBook.section;
|
||||||
|
const adjacent = isAdjacent(guessBookId, correctBookId);
|
||||||
|
|
||||||
|
// Special case: if correct book is in the Epistles + starts with "1",
|
||||||
|
// any guess starting with "1" counts as first letter match
|
||||||
|
const correctIsEpistlesWithNumber =
|
||||||
|
(correctBook.section === "Pauline Epistles" ||
|
||||||
|
correctBook.section === "General Epistles") &&
|
||||||
|
correctBook.name[0] === "1";
|
||||||
|
const guessIsEpistlesWithNumber =
|
||||||
|
(book.section === "Pauline Epistles" ||
|
||||||
|
book.section === "General Epistles") &&
|
||||||
|
book.name[0] === "1";
|
||||||
|
|
||||||
|
const firstLetterMatch =
|
||||||
|
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||||
|
? true
|
||||||
|
: getFirstLetter(book.name).toUpperCase() ===
|
||||||
|
getFirstLetter(correctBook.name).toUpperCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
book,
|
||||||
|
testamentMatch,
|
||||||
|
sectionMatch,
|
||||||
|
adjacent,
|
||||||
|
firstLetterMatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGrade(numGuesses: number): string {
|
||||||
if (numGuesses === 1) return "S+";
|
if (numGuesses === 1) return "S+";
|
||||||
if (numGuesses === 2) return "A+";
|
if (numGuesses === 2) return "A+";
|
||||||
if (numGuesses === 3) return "A";
|
if (numGuesses === 3) return "A";
|
||||||
@@ -31,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toOrdinal(n: number): string {
|
export function toOrdinal(n: number): string {
|
||||||
if (n >= 11 && n <= 13) {
|
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||||
return `${n}th`;
|
return `${n}th`;
|
||||||
}
|
}
|
||||||
const mod = n % 10;
|
const mod = n % 10;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Guess } from './game';
|
||||||
|
|
||||||
|
export function getVerseSnippet(verseText: string): string {
|
||||||
|
const words = verseText.trim().split(/\s+/);
|
||||||
|
const slice = words.slice(0, 25);
|
||||||
|
const text = slice.join(' ');
|
||||||
|
|
||||||
|
// Returns character index immediately after the Nth word (1-indexed)
|
||||||
|
function posAfterWord(n: number): number {
|
||||||
|
let pos = 0;
|
||||||
|
for (let w = 0; w < Math.min(n, slice.length); w++) {
|
||||||
|
if (w > 0) pos++; // space between words
|
||||||
|
pos += slice[w].length;
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = posAfterWord(9);
|
||||||
|
const end = posAfterWord(25);
|
||||||
|
|
||||||
|
// Find first punctuation mark between words 10 and 25
|
||||||
|
const range = text.substring(start, end);
|
||||||
|
const match = range.match(/[,;:.!?—–-]/);
|
||||||
|
|
||||||
|
function withClosedQuotes(snippet: string): string {
|
||||||
|
const opens = (snippet.match(/\u201C/g) ?? []).length;
|
||||||
|
const closes = (snippet.match(/\u201D/g) ?? []).length;
|
||||||
|
const closeQuote = opens > closes ? '\u201D' : '';
|
||||||
|
return `\u201C${snippet}...${closeQuote}\u201D`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
const cutPos = start + match.index;
|
||||||
|
return withClosedQuotes(text.substring(0, cutPos).trimEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
return withClosedQuotes(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateShareText(params: {
|
||||||
|
guesses: Guess[];
|
||||||
|
correctBookId: string;
|
||||||
|
dailyVerseDate: string;
|
||||||
|
chapterCorrect: boolean;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
streak?: number;
|
||||||
|
origin: string;
|
||||||
|
verseText: string;
|
||||||
|
}): string {
|
||||||
|
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
|
||||||
|
|
||||||
|
const emojis = guesses
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((guess) => {
|
||||||
|
if (guess.book.id === correctBookId) return "✅";
|
||||||
|
if (guess.adjacent) return "‼️";
|
||||||
|
if (guess.sectionMatch) return "🟩";
|
||||||
|
if (guess.testamentMatch) return "🟧";
|
||||||
|
return "🟥";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const formattedDate = dateFormatter.format(
|
||||||
|
new Date(`${dailyVerseDate}T00:00:00`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
||||||
|
|
||||||
|
const guessWord = guesses.length === 1 ? "guess" : "guesses";
|
||||||
|
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
|
||||||
|
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||||
|
`${guesses.length} ${guessWord}${streakPart ? `,${streakPart}` : ""}`,
|
||||||
|
`${emojis}${chapterStar}`
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareResult(shareText: string): Promise<void> {
|
||||||
|
if ("share" in navigator) {
|
||||||
|
await (navigator as any).share({ text: shareText });
|
||||||
|
} else {
|
||||||
|
await (navigator as any).clipboard.writeText(shareText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyToClipboard(shareText: string): Promise<void> {
|
||||||
|
await (navigator as any).clipboard.writeText(shareText);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export interface StatsData {
|
||||||
|
solveRank: number;
|
||||||
|
guessRank: number;
|
||||||
|
totalSolves: number;
|
||||||
|
averageGuesses: number;
|
||||||
|
tiedCount: number;
|
||||||
|
percentile: number;
|
||||||
|
guesses?: string[]; // Present when fetching an existing completion (cross-device sync)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitCompletion(params: {
|
||||||
|
anonymousId: string;
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
guesses: string[];
|
||||||
|
}): Promise<StatsData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/submit-completion", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.stats) {
|
||||||
|
return result.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
// Already submitted from another device — fetch existing stats
|
||||||
|
return fetchExistingStats({ anonymousId: params.anonymousId, date: params.date });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error("Stats server error:", result.error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Stats submission failed:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExistingStats(params: {
|
||||||
|
anonymousId: string;
|
||||||
|
date: string;
|
||||||
|
}): Promise<StatsData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/stats?anonymousId=${params.anonymousId}&date=${params.date}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.stats) {
|
||||||
|
return result.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error("Stats server error:", result.error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Stats fetch failed:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export interface UserStats {
|
||||||
|
totalSolves: number;
|
||||||
|
avgGuesses: number;
|
||||||
|
gradeDistribution: {
|
||||||
|
'S++': number;
|
||||||
|
'S+': number;
|
||||||
|
'A+': number;
|
||||||
|
'A': number;
|
||||||
|
'B+': number;
|
||||||
|
'B': number;
|
||||||
|
'C+': number;
|
||||||
|
'C': number;
|
||||||
|
};
|
||||||
|
currentStreak: number;
|
||||||
|
bestStreak: number;
|
||||||
|
recentCompletions: Array<{
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
grade: string;
|
||||||
|
}>;
|
||||||
|
worstDay: {
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
} | null;
|
||||||
|
bestBook: {
|
||||||
|
bookId: string;
|
||||||
|
avgGuesses: number;
|
||||||
|
count: number;
|
||||||
|
} | null;
|
||||||
|
mostSeenBook: {
|
||||||
|
bookId: string;
|
||||||
|
count: number;
|
||||||
|
} | null;
|
||||||
|
totalBooksSeenOT: number;
|
||||||
|
totalBooksSeenNT: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGradeColor(grade: string): string {
|
||||||
|
switch (grade) {
|
||||||
|
case 'S++': return 'text-purple-600 bg-purple-100';
|
||||||
|
case 'S+': return 'text-yellow-600 bg-yellow-100';
|
||||||
|
case 'A+': return 'text-green-600 bg-green-100';
|
||||||
|
case 'A': return 'text-green-500 bg-green-50';
|
||||||
|
case 'B+': return 'text-blue-600 bg-blue-100';
|
||||||
|
case 'B': return 'text-blue-500 bg-blue-50';
|
||||||
|
case 'C+': return 'text-orange-600 bg-orange-100';
|
||||||
|
case 'C': return 'text-red-600 bg-red-100';
|
||||||
|
default: return 'text-gray-600 bg-gray-100';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreakMessage(currentStreak: number): string {
|
||||||
|
if (currentStreak === 0) {
|
||||||
|
return "Start your streak today!";
|
||||||
|
} else if (currentStreak === 1) {
|
||||||
|
return "Keep it going!";
|
||||||
|
} else if (currentStreak < 7) {
|
||||||
|
return `${currentStreak} days strong!`;
|
||||||
|
} else if (currentStreak < 30) {
|
||||||
|
return `${currentStreak} day streak - amazing!`;
|
||||||
|
} else {
|
||||||
|
return `${currentStreak} days - you're unstoppable!`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPerformanceMessage(avgGuesses: number): string {
|
||||||
|
if (avgGuesses <= 2) {
|
||||||
|
return "Exceptional performance!";
|
||||||
|
} else if (avgGuesses <= 4) {
|
||||||
|
return "Great performance!";
|
||||||
|
} else if (avgGuesses <= 6) {
|
||||||
|
return "Good performance!";
|
||||||
|
} else if (avgGuesses <= 8) {
|
||||||
|
return "Room for improvement!";
|
||||||
|
} else {
|
||||||
|
return "Keep practicing!";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export async function fetchStreak(anonymousId: string, localDate: string): Promise<number> {
|
||||||
|
const params = new URLSearchParams({ anonymousId, localDate });
|
||||||
|
const res = await fetch(`/api/streak?${params}`);
|
||||||
|
if (!res.ok) return 0;
|
||||||
|
const data = await res.json();
|
||||||
|
return typeof data.streak === 'number' ? data.streak : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStreakPercentile(streak: number, localDate: string): Promise<number | null> {
|
||||||
|
const params = new URLSearchParams({ streak: String(streak), localDate });
|
||||||
|
const res = await fetch(`/api/streak-percentile?${params}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return typeof data.percentile === 'number' ? data.percentile : null;
|
||||||
|
}
|
||||||
+52
-12
@@ -1,18 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "./layout.css";
|
import { onMount } from "svelte";
|
||||||
import favicon from "$lib/assets/favicon.ico";
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
let { children } = $props();
|
import "./layout.css";
|
||||||
|
import favicon from "$lib/assets/favicon.ico";
|
||||||
|
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||||
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
|
||||||
|
let isDev = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
isDev =
|
||||||
|
window.location.host === "localhost:5173" ||
|
||||||
|
window.location.host === "test.bibdle.com";
|
||||||
|
|
||||||
|
// Inject analytics script
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.defer = true;
|
||||||
|
script.src = "https://umami.snail.city/script.js";
|
||||||
|
script.setAttribute(
|
||||||
|
"data-website-id",
|
||||||
|
"5b8c31ad-71cd-4317-940b-6bccea732acc",
|
||||||
|
);
|
||||||
|
script.setAttribute("data-domains", "bibdle.com,www.bibdle.com");
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
<link
|
||||||
<script
|
rel="alternate"
|
||||||
defer
|
type="application/rss+xml"
|
||||||
src="https://umami.snail.city/script.js"
|
title="Bibdle RSS Feed"
|
||||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
href="/feed.xml"
|
||||||
data-domains="bibdle.com,www.bibdle.com"
|
/>
|
||||||
></script>
|
<meta name="description" content="A Wordle-inspired daily Bible game" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
{@render children()}
|
|
||||||
|
<div
|
||||||
|
class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
|
||||||
|
>
|
||||||
|
<TitleAnimation />
|
||||||
|
<div class="font-normal"></div>
|
||||||
|
</h1>
|
||||||
|
{#if isDev}
|
||||||
|
<div class="flex justify-center pb-2"><ThemeToggle /></div>
|
||||||
|
{:else}
|
||||||
|
<div class="justify-center hidden pb-2"><ThemeToggle /></div>
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,47 +1,14 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
import { eq, sql, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
|
||||||
import { getBookById } from '$lib/server/bible';
|
|
||||||
import type { DailyVerse } from '$lib/server/db/schema';
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
async function getTodayVerse(): Promise<DailyVerse> {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
// Get the current date (server-side)
|
|
||||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
|
||||||
|
|
||||||
// If there's an existing verse for the current date, return it
|
|
||||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
|
||||||
if (existing.length > 0) {
|
|
||||||
return existing[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise get a new random verse
|
|
||||||
const apiVerse = await fetchRandomVerse();
|
|
||||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
|
||||||
|
|
||||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
date: dateStr,
|
|
||||||
bookId: apiVerse.bookId,
|
|
||||||
verseText: apiVerse.verseText,
|
|
||||||
reference: apiVerse.reference,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
|
||||||
return inserted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
|
||||||
const dailyVerse = await getTodayVerse();
|
|
||||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dailyVerse,
|
user: locals.user,
|
||||||
correctBookId: dailyVerse.bookId,
|
session: locals.session
|
||||||
correctBook
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+413
-391
@@ -1,52 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
|
||||||
|
|
||||||
import type { PageProps } from "./$types";
|
import type { PageProps } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||||
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
||||||
import WinScreen from "$lib/components/WinScreen.svelte";
|
import WinScreen from "$lib/components/WinScreen.svelte";
|
||||||
import Credits from "$lib/components/Credits.svelte";
|
import Credits from "$lib/components/Credits.svelte";
|
||||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
|
||||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
|
||||||
import { getGrade } from "$lib/utils/game";
|
|
||||||
|
|
||||||
interface Guess {
|
import GamePrompt from "$lib/components/GamePrompt.svelte";
|
||||||
book: BibleBook;
|
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||||
testamentMatch: boolean;
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
sectionMatch: boolean;
|
|
||||||
adjacent: boolean;
|
import { evaluateGuess, getFirstLetter } from "$lib/utils/game";
|
||||||
firstLetterMatch: boolean;
|
import {
|
||||||
}
|
generateShareText,
|
||||||
|
shareResult,
|
||||||
|
copyToClipboard as clipboardCopy,
|
||||||
|
} from "$lib/utils/share";
|
||||||
|
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
|
||||||
|
import {
|
||||||
|
submitCompletion,
|
||||||
|
fetchExistingStats,
|
||||||
|
type StatsData,
|
||||||
|
} from "$lib/utils/stats-client";
|
||||||
|
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let correctBookId = $derived(data.correctBookId);
|
let correctBookId = $derived(data.correctBookId);
|
||||||
|
let correctBook = $derived(data.correctBook);
|
||||||
let guesses = $state<Guess[]>([]);
|
let user = $derived(data.user);
|
||||||
|
let session = $derived(data.session);
|
||||||
let searchQuery = $state("");
|
|
||||||
|
|
||||||
let copied = $state(false);
|
|
||||||
let isDev = $state(false);
|
|
||||||
let chapterGuessCompleted = $state(false);
|
|
||||||
let chapterCorrect = $state(false);
|
|
||||||
|
|
||||||
let anonymousId = $state("");
|
|
||||||
let statsSubmitted = $state(false);
|
|
||||||
let statsData = $state<{
|
|
||||||
solveRank: number;
|
|
||||||
guessRank: number;
|
|
||||||
totalSolves: number;
|
|
||||||
averageGuesses: number;
|
|
||||||
tiedCount: number;
|
|
||||||
percentile: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
|
|
||||||
|
|
||||||
const currentDate = $derived(
|
const currentDate = $derived(
|
||||||
new Date().toLocaleDateString("en-US", {
|
new Date().toLocaleDateString("en-US", {
|
||||||
@@ -57,75 +46,103 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
let searchQuery = $state("");
|
||||||
let grade = $derived(
|
let copied = $state(false);
|
||||||
isWon
|
let isDev = $state(false);
|
||||||
? guesses.length === 1 && chapterCorrect
|
let authModalOpen = $state(false);
|
||||||
? "S++"
|
let showWinScreen = $state(false);
|
||||||
: getGrade(
|
let statsData = $state<StatsData | null>(null);
|
||||||
guesses.length,
|
let streak = $state(0);
|
||||||
getBookById(correctBookId)?.popularity ?? 0,
|
let streakPercentile = $state<number | null>(null);
|
||||||
)
|
let guessesMinimized = $state(false);
|
||||||
: "",
|
|
||||||
|
const persistence = createGamePersistence(
|
||||||
|
() => dailyVerse.date,
|
||||||
|
() => dailyVerse.reference,
|
||||||
|
() => correctBookId,
|
||||||
|
() => user,
|
||||||
|
);
|
||||||
|
|
||||||
|
let guessedIds = $derived(
|
||||||
|
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let isWon = $derived(
|
||||||
|
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||||
);
|
);
|
||||||
let blurChapter = $derived(
|
let blurChapter = $derived(
|
||||||
isWon && guesses.length === 1 && !chapterGuessCompleted,
|
isWon &&
|
||||||
|
persistence.guesses.length === 1 &&
|
||||||
|
!persistence.chapterGuessCompleted,
|
||||||
);
|
);
|
||||||
|
|
||||||
function getBookById(id: string): BibleBook | undefined {
|
let knownTestament = $derived(
|
||||||
return bibleBooks.find((b) => b.id === id);
|
persistence.guesses.some((g) => g.testamentMatch)
|
||||||
}
|
? correctBook?.testament
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
let knownSection = $derived(
|
||||||
|
persistence.guesses.some((g) => g.sectionMatch)
|
||||||
|
? correctBook?.section
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
let knownFirstLetter = $derived(
|
||||||
|
persistence.guesses.some((g) => g.firstLetterMatch)
|
||||||
|
? getFirstLetter(correctBook?.name ?? "").toUpperCase()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
function isAdjacent(id1: string, id2: string): boolean {
|
let testamentVisible = $state(false);
|
||||||
const b1 = getBookById(id1);
|
let sectionVisible = $state(false);
|
||||||
const b2 = getBookById(id2);
|
let firstLetterVisible = $state(false);
|
||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
let showHints = $state(false);
|
||||||
}
|
|
||||||
|
|
||||||
function getFirstLetter(bookName: string): string {
|
// On page load, show hints that are already known without animation
|
||||||
const match = bookName.match(/[a-zA-Z]/);
|
onMount(() => {
|
||||||
return match ? match[0] : bookName[0];
|
if (knownTestament) testamentVisible = true;
|
||||||
}
|
if (knownSection) sectionVisible = true;
|
||||||
|
if (knownFirstLetter) firstLetterVisible = true;
|
||||||
|
|
||||||
function submitGuess(bookId: string) {
|
const winCount = Object.keys(localStorage).filter(
|
||||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
(k) => k.startsWith("bibdle-win-tracked-") && localStorage.getItem(k) === "true"
|
||||||
|
).length;
|
||||||
|
showHints = winCount < 3;
|
||||||
|
});
|
||||||
|
|
||||||
const book = getBookById(bookId);
|
// Fade in newly revealed hints after the guess animation completes
|
||||||
if (!book) return;
|
$effect(() => {
|
||||||
|
if (!knownTestament || testamentVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
testamentVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!knownSection || sectionVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
sectionVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!knownFirstLetter || firstLetterVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
firstLetterVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
|
||||||
const correctBook = getBookById(correctBookId);
|
async function submitGuess(bookId: string) {
|
||||||
if (!correctBook) return;
|
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||||
|
|
||||||
const testamentMatch = book.testament === correctBook.testament;
|
const guess = evaluateGuess(bookId, correctBookId);
|
||||||
const sectionMatch = book.section === correctBook.section;
|
if (!guess) return;
|
||||||
const adjacent = isAdjacent(bookId, correctBookId);
|
|
||||||
|
|
||||||
// Special case: if correct book is in the Epistles + starts with "1",
|
if (persistence.guesses.length === 0) {
|
||||||
// any guess starting with "1" counts as first letter match
|
|
||||||
const correctIsEpistlesWithNumber =
|
|
||||||
(correctBook.section === "Pauline Epistles" ||
|
|
||||||
correctBook.section === "General Epistles") &&
|
|
||||||
correctBook.name[0] === "1";
|
|
||||||
const guessIsEpistlesWithNumber =
|
|
||||||
(book.section === "Pauline Epistles" ||
|
|
||||||
book.section === "General Epistles") &&
|
|
||||||
book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
|
||||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
|
||||||
? true
|
|
||||||
: getFirstLetter(book.name).toUpperCase() ===
|
|
||||||
getFirstLetter(correctBook.name).toUpperCase();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (guesses.length === 0) {
|
|
||||||
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
||||||
if (
|
if (
|
||||||
localStorage.getItem(key) !== "true" &&
|
|
||||||
browser &&
|
browser &&
|
||||||
|
localStorage.getItem(key) !== "true" &&
|
||||||
(window as any).umami
|
(window as any).umami
|
||||||
) {
|
) {
|
||||||
(window as any).umami.track("First guess");
|
(window as any).umami.track("First guess");
|
||||||
@@ -133,285 +150,170 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guesses = [
|
persistence.guesses = [guess, ...persistence.guesses];
|
||||||
{
|
|
||||||
book,
|
|
||||||
testamentMatch,
|
|
||||||
sectionMatch,
|
|
||||||
adjacent,
|
|
||||||
firstLetterMatch,
|
|
||||||
},
|
|
||||||
...guesses,
|
|
||||||
];
|
|
||||||
|
|
||||||
searchQuery = "";
|
searchQuery = "";
|
||||||
}
|
|
||||||
|
|
||||||
function generateUUID(): string {
|
if (
|
||||||
// Try native randomUUID if available
|
guess.book.id === correctBookId &&
|
||||||
if (typeof window.crypto.randomUUID === "function") {
|
browser &&
|
||||||
return window.crypto.randomUUID();
|
persistence.anonymousId
|
||||||
}
|
) {
|
||||||
|
statsData = await submitCompletion({
|
||||||
// Fallback UUID v4 generator for older browsers
|
anonymousId: persistence.anonymousId,
|
||||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
date: dailyVerse.date,
|
||||||
const r =
|
guessCount: persistence.guesses.length,
|
||||||
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
|
guesses: persistence.guesses.map((g) => g.book.id),
|
||||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateAnonymousId(): string {
|
|
||||||
if (!browser) return "";
|
|
||||||
const key = "bibdle-anonymous-id";
|
|
||||||
let id = localStorage.getItem(key);
|
|
||||||
if (!id) {
|
|
||||||
id = generateUUID();
|
|
||||||
localStorage.setItem(key, id);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize anonymous ID
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
anonymousId = getOrCreateAnonymousId();
|
|
||||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
|
||||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
|
||||||
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
|
||||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
|
||||||
if (chapterGuessCompleted) {
|
|
||||||
const saved = localStorage.getItem(chapterGuessKey);
|
|
||||||
if (saved) {
|
|
||||||
const data = JSON.parse(saved);
|
|
||||||
const match = dailyVerse.reference.match(/\s(\d+):/);
|
|
||||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
|
||||||
chapterCorrect = data.selectedChapter === correctChapter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
isDev = window.location.host === "localhost:5173";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load saved guesses
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
|
|
||||||
const key = `bibdle-guesses-${dailyVerse.date}`;
|
|
||||||
const saved = localStorage.getItem(key);
|
|
||||||
if (saved) {
|
|
||||||
let savedIds: string[] = JSON.parse(saved);
|
|
||||||
savedIds = Array.from(new Set(savedIds));
|
|
||||||
guesses = savedIds.map((bookId: string) => {
|
|
||||||
const book = getBookById(bookId)!;
|
|
||||||
const correctBook = getBookById(correctBookId)!;
|
|
||||||
const testamentMatch = book.testament === correctBook.testament;
|
|
||||||
const sectionMatch = book.section === correctBook.section;
|
|
||||||
const adjacent = isAdjacent(bookId, correctBookId);
|
|
||||||
|
|
||||||
// Apply same first letter logic as in submitGuess
|
|
||||||
const correctIsEpistlesWithNumber =
|
|
||||||
(correctBook.section === "Pauline Epistles" ||
|
|
||||||
correctBook.section === "General Epistles") &&
|
|
||||||
correctBook.name[0] === "1";
|
|
||||||
const guessIsEpistlesWithNumber =
|
|
||||||
(book.section === "Pauline Epistles" ||
|
|
||||||
book.section === "General Epistles") &&
|
|
||||||
book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
|
||||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
|
||||||
? true
|
|
||||||
: getFirstLetter(book.name).toUpperCase() ===
|
|
||||||
getFirstLetter(correctBook.name).toUpperCase();
|
|
||||||
|
|
||||||
return {
|
|
||||||
book,
|
|
||||||
testamentMatch,
|
|
||||||
sectionMatch,
|
|
||||||
adjacent,
|
|
||||||
firstLetterMatch,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
if (statsData) {
|
||||||
|
persistence.markStatsSubmitted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload when the user returns to a stale tab on a new calendar day
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const loadedDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (document.hidden) return;
|
||||||
|
const now = new Date().toLocaleDateString("en-CA");
|
||||||
|
if (now !== loadedDate) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
onVisibilityChange,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
localStorage.setItem(
|
isDev =
|
||||||
`bibdle-guesses-${dailyVerse.date}`,
|
window.location.host === "localhost:5173" ||
|
||||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
window.location.host === "test.bibdle.com";
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-submit stats when user wins
|
// Fetch stats on page load if user already won in a previous session (same device)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
console.log("Stats effect triggered:", {
|
if (
|
||||||
browser,
|
!browser ||
|
||||||
isWon,
|
!isWon ||
|
||||||
anonymousId,
|
!persistence.anonymousId ||
|
||||||
statsSubmitted,
|
statsData ||
|
||||||
statsData,
|
!persistence.statsSubmitted
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
fetchExistingStats({
|
||||||
|
anonymousId: persistence.anonymousId,
|
||||||
|
date: dailyVerse.date,
|
||||||
|
}).then((data) => {
|
||||||
|
statsData = data;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!browser || !isWon || !anonymousId) {
|
|
||||||
console.log("Basic conditions not met");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statsSubmitted && !statsData) {
|
|
||||||
console.log("Fetching existing stats...");
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
console.log("Stats response:", result);
|
|
||||||
|
|
||||||
if (result.success && result.stats) {
|
|
||||||
console.log("Setting stats data:", result.stats);
|
|
||||||
statsData = result.stats;
|
|
||||||
localStorage.setItem(
|
|
||||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
} else if (result.error) {
|
|
||||||
console.error("Server error:", result.error);
|
|
||||||
} else {
|
|
||||||
console.error("Unexpected response format:", result);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Stats fetch failed:", err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Submitting stats...");
|
|
||||||
|
|
||||||
async function submitStats() {
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
anonymousId,
|
|
||||||
date: dailyVerse.date,
|
|
||||||
guessCount: guesses.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("Sending POST request with:", payload);
|
|
||||||
|
|
||||||
const response = await fetch("/api/submit-completion", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log("Stats response:", result);
|
|
||||||
|
|
||||||
if (result.success && result.stats) {
|
|
||||||
console.log("Setting stats data:", result.stats);
|
|
||||||
statsData = result.stats;
|
|
||||||
statsSubmitted = true;
|
|
||||||
localStorage.setItem(
|
|
||||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
} else if (result.error) {
|
|
||||||
console.error("Server error:", result.error);
|
|
||||||
} else {
|
|
||||||
console.error("Unexpected response format:", result);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Stats submission failed:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submitStats();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// For logged-in users on a new device: restore today's game state from the server.
|
||||||
|
// Runs even when isWon is true so that logging in after completing the game on another
|
||||||
|
// device always replaces local localStorage with the authoritative DB record.
|
||||||
|
let crossDeviceCheckDate = $state<string | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
!browser ||
|
||||||
|
!user ||
|
||||||
|
!dailyVerse?.date ||
|
||||||
|
crossDeviceCheckDate === dailyVerse.date ||
|
||||||
|
!persistence.anonymousId
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
crossDeviceCheckDate = dailyVerse.date;
|
||||||
|
fetchExistingStats({
|
||||||
|
anonymousId: persistence.anonymousId,
|
||||||
|
date: dailyVerse.date,
|
||||||
|
}).then((data) => {
|
||||||
|
if (data?.guesses?.length) {
|
||||||
|
persistence.hydrateFromServer(data.guesses);
|
||||||
|
statsData = data;
|
||||||
|
persistence.markStatsSubmitted();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay showing win screen until GuessesTable animation completes
|
||||||
|
$effect(() => {
|
||||||
|
if (!isWon) {
|
||||||
|
showWinScreen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persistence.isWinAlreadyTracked()) {
|
||||||
|
showWinScreen = true;
|
||||||
|
} else {
|
||||||
|
const animationDelay = 1800;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
showWinScreen = true;
|
||||||
|
}, animationDelay);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay collapsing the guesses grid until animations complete (mirrors showWinScreen delay)
|
||||||
|
$effect(() => {
|
||||||
|
if (!isWon || persistence.guesses.length <= 3) {
|
||||||
|
guessesMinimized = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (persistence.isWinAlreadyTracked()) {
|
||||||
|
guessesMinimized = true;
|
||||||
|
} else {
|
||||||
|
const animationDelay = 1800;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
guessesMinimized = true;
|
||||||
|
}, animationDelay);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track win analytics
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser || !isWon) return;
|
if (!browser || !isWon) return;
|
||||||
const key = `bibdle-win-tracked-${dailyVerse.date}`;
|
const isNew = persistence.markWinTracked();
|
||||||
if (localStorage.getItem(key) === "true") return;
|
if (isNew && (window as any).umami) {
|
||||||
if ((window as any).umami) {
|
|
||||||
(window as any).umami.track("Guessed correctly", {
|
(window as any).umami.track("Guessed correctly", {
|
||||||
totalGuesses: guesses.length,
|
totalGuesses: persistence.guesses.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
localStorage.setItem(key, "true");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateShareText(): string {
|
// Fetch streak when the player wins
|
||||||
const emojis = guesses
|
$effect(() => {
|
||||||
.slice()
|
if (!browser || !isWon || !persistence.anonymousId) return;
|
||||||
.reverse()
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
.map((guess) => {
|
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||||
if (guess.book.id === correctBookId) return "✅";
|
streak = result;
|
||||||
if (guess.adjacent) return "‼️";
|
if (result >= 2) {
|
||||||
if (guess.sectionMatch) return "🟩";
|
fetchStreakPercentile(result, localDate).then((p) => {
|
||||||
if (guess.testamentMatch) return "🟧";
|
streakPercentile = p;
|
||||||
return "🟥";
|
});
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
const formattedDate = dateFormatter.format(
|
|
||||||
new Date(`${dailyVerse.date}T00:00:00`),
|
|
||||||
);
|
|
||||||
const siteUrl = window.location.origin;
|
|
||||||
return [
|
|
||||||
`📖 Bibdle | ${formattedDate} 📖`,
|
|
||||||
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
|
||||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
|
||||||
siteUrl,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function share() {
|
|
||||||
if (!browser) return;
|
|
||||||
|
|
||||||
const shareText = generateShareText();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ("share" in navigator) {
|
|
||||||
await (navigator as any).share({ text: shareText });
|
|
||||||
} else {
|
|
||||||
await (navigator as any).clipboard.writeText(shareText);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
});
|
||||||
console.error("Share failed:", err);
|
});
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyToClipboard() {
|
function getShareText(): string {
|
||||||
if (!browser) return;
|
return generateShareText({
|
||||||
|
guesses: persistence.guesses,
|
||||||
const shareText = generateShareText();
|
correctBookId,
|
||||||
|
dailyVerseDate: dailyVerse.date,
|
||||||
try {
|
chapterCorrect: persistence.chapterCorrect,
|
||||||
await (navigator as any).clipboard.writeText(shareText);
|
isLoggedIn: !!user,
|
||||||
copied = true;
|
streak,
|
||||||
setTimeout(() => {
|
origin: window.location.origin,
|
||||||
copied = false;
|
verseText: dailyVerse.verseText,
|
||||||
}, 5000);
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error("Copy to clipboard failed:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShare() {
|
function handleShare() {
|
||||||
@@ -420,7 +322,7 @@
|
|||||||
if (useClipboard) {
|
if (useClipboard) {
|
||||||
copied = true;
|
copied = true;
|
||||||
}
|
}
|
||||||
share()
|
shareResult(getShareText())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (useClipboard) {
|
if (useClipboard) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -434,72 +336,192 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCopyToClipboard() {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
await clipboardCopy(getShareText());
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 5000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Copy to clipboard failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||||
<!-- <meta
|
|
||||||
name="description"
|
|
||||||
content="Guess which book of the Bible a verse comes from."
|
|
||||||
/> -->
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
|
<div class="pb-8">
|
||||||
<div class="w-full max-w-3xl mx-auto px-4">
|
<div class="w-full max-w-3xl mx-auto px-4">
|
||||||
<h1
|
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
|
|
||||||
>
|
|
||||||
<TitleAnimation />
|
|
||||||
<div class="font-normal"></div>
|
|
||||||
</h1>
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<span class="big-text"
|
<span class="big-text"
|
||||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
<div class="animate-fade-in-up animate-delay-200">
|
||||||
|
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
{:else}
|
<GamePrompt guessCount={persistence.guesses.length} />
|
||||||
<WinScreen
|
|
||||||
{grade}
|
{#if showHints && (knownTestament || knownSection || knownFirstLetter)}
|
||||||
{statsData}
|
<div
|
||||||
{correctBookId}
|
class="text-xs uppercase tracking-widest font-bold text-center text-gray-500 dark:text-gray-400 flex flex-col gap-1 mb-4 -mt-2"
|
||||||
{handleShare}
|
>
|
||||||
{copyToClipboard}
|
{#if knownTestament}
|
||||||
bind:copied
|
<p
|
||||||
{statsSubmitted}
|
style="transition: opacity 0.5s ease; opacity: {testamentVisible
|
||||||
guessCount={guesses.length}
|
? 1
|
||||||
reference={dailyVerse.reference}
|
: 0};"
|
||||||
onChapterGuessCompleted={() => {
|
>
|
||||||
chapterGuessCompleted = true;
|
It is in the {knownTestament === "old"
|
||||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
? "Old"
|
||||||
const saved = localStorage.getItem(key);
|
: "New"} Testament.
|
||||||
if (saved) {
|
</p>
|
||||||
const data = JSON.parse(saved);
|
{/if}
|
||||||
const match =
|
{#if knownSection}
|
||||||
dailyVerse.reference.match(/\s(\d+):/);
|
<p
|
||||||
const correctChapter = match
|
style="transition: opacity 0.5s ease; opacity: {sectionVisible
|
||||||
? parseInt(match[1], 10)
|
? 1
|
||||||
: 1;
|
: 0};"
|
||||||
chapterCorrect =
|
>
|
||||||
data.selectedChapter === correctChapter;
|
It is in the {knownSection} section.
|
||||||
}
|
</p>
|
||||||
}}
|
{/if}
|
||||||
/>
|
{#if knownFirstLetter}
|
||||||
|
<p
|
||||||
|
style="transition: opacity 0.5s ease; opacity: {firstLetterVisible
|
||||||
|
? 1
|
||||||
|
: 0};"
|
||||||
|
>
|
||||||
|
The book's name starts with "{knownFirstLetter}".
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<SearchInput
|
||||||
|
bind:searchQuery
|
||||||
|
{guessedIds}
|
||||||
|
{submitGuess}
|
||||||
|
guessCount={persistence.guesses.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if showWinScreen}
|
||||||
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
|
<WinScreen
|
||||||
|
{statsData}
|
||||||
|
{correctBookId}
|
||||||
|
{handleShare}
|
||||||
|
copyToClipboard={handleCopyToClipboard}
|
||||||
|
bind:copied
|
||||||
|
statsSubmitted={persistence.statsSubmitted}
|
||||||
|
guessCount={persistence.guesses.length}
|
||||||
|
reference={dailyVerse.reference}
|
||||||
|
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||||
|
shareText={getShareText()}
|
||||||
|
verseText={dailyVerse.verseText}
|
||||||
|
{streak}
|
||||||
|
{streakPercentile}
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
anonymousId={persistence.anonymousId}
|
||||||
|
localDate={new Date().toLocaleDateString("en-CA")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<GuessesTable {guesses} {correctBookId} />
|
<div class="animate-fade-in-up animate-delay-600">
|
||||||
|
<GuessesTable
|
||||||
|
guesses={persistence.guesses}
|
||||||
|
{correctBookId}
|
||||||
|
minimized={guessesMinimized}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if isWon}
|
{#if isWon}
|
||||||
<Credits />
|
<hr
|
||||||
|
class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<div class="animate-fade-in-up animate-delay-800">
|
||||||
|
<Credits />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isDev}
|
{#if isDev}
|
||||||
<DevButtons />
|
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded border dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div><strong>Debug Info:</strong></div>
|
||||||
|
<div>
|
||||||
|
User: {user
|
||||||
|
? `${user.email} (ID: ${user.id})`
|
||||||
|
: "Not signed in"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Session: {session
|
||||||
|
? `Expires ${session.expiresAt.toLocaleDateString()}`
|
||||||
|
: "No session"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Anonymous ID: {persistence.anonymousId || "Not set"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Client Local Time: {new Date().toLocaleString("en-US", {
|
||||||
|
timeZone:
|
||||||
|
Intl.DateTimeFormat().resolvedOptions()
|
||||||
|
.timeZone,
|
||||||
|
timeZoneName: "short",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Client Local Date: {new Date().toLocaleDateString(
|
||||||
|
"en-CA",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||||
|
<div>Streak: {streak}</div>
|
||||||
|
</div>
|
||||||
|
<DevButtons
|
||||||
|
anonymousId={persistence.anonymousId}
|
||||||
|
{user}
|
||||||
|
onSignIn={() => (authModalOpen = true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if user && session}
|
||||||
|
<div
|
||||||
|
class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center text-xs text-gray-400 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
Signed in as {[user.firstName, user.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}{user.email
|
||||||
|
? ` (${user.email})`
|
||||||
|
: ""}{user.appleId ? " using Apple" : user.googleId ? " using Google" : ""} |
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/auth/logout"
|
||||||
|
use:enhance
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
|
||||||
|
>Sign out</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AuthModal bind:isOpen={authModalOpen} anonymousId={persistence.anonymousId} />
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
// Disable SSR so the load function runs on the client with the correct local date
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, data }) => {
|
||||||
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
|
||||||
|
const res = await fetch('/api/daily-verse', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ date: localDate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
dailyVerse: result.dailyVerse,
|
||||||
|
correctBookId: result.correctBookId,
|
||||||
|
correctBook: result.correctBook,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
const about = readFileSync(resolve('static/about.md'), 'utf-8');
|
||||||
|
const howToPlay = readFileSync(resolve('static/how-to-play.md'), 'utf-8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
about: await marked(about),
|
||||||
|
howToPlay: await marked(howToPlay)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<svelte:head>
|
||||||
|
<title>About — Bibdle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const SOCIAL_PLACEHOLDER = "<!-- social -->";
|
||||||
|
|
||||||
|
const aboutParts = $derived(
|
||||||
|
data.about.includes(SOCIAL_PLACEHOLDER)
|
||||||
|
? data.about.split(SOCIAL_PLACEHOLDER)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-dvh py-10 px-4">
|
||||||
|
<div class="w-full max-w-xl mx-auto">
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose dark:prose-invert text-justify max-w-none">
|
||||||
|
{#if aboutParts}
|
||||||
|
{@html aboutParts[0]}
|
||||||
|
<div class="my-8 not-prose">
|
||||||
|
<SocialLinks />
|
||||||
|
</div>
|
||||||
|
{@html aboutParts[1]}
|
||||||
|
{:else}
|
||||||
|
{@html data.about}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose dark:prose-invert text-justify max-w-none mt-10">
|
||||||
|
{@html data.howToPlay}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { bibleBooks } from '$lib/server/bible';
|
||||||
|
import { getChapterCount, getVerseCount } from '$lib/server/xml-bible';
|
||||||
|
|
||||||
|
// Static Bible structure for the cascading Book → Chapter → Verse selectors.
|
||||||
|
// ~1189 chapter entries total; safe to cache indefinitely.
|
||||||
|
let cached: { bookId: string; chapters: number[] }[] | null = null;
|
||||||
|
|
||||||
|
function build(): { bookId: string; chapters: number[] }[] {
|
||||||
|
if (cached) return cached;
|
||||||
|
const out = bibleBooks.map((book) => {
|
||||||
|
const chapterCount = getChapterCount(book.order);
|
||||||
|
const chapters: number[] = new Array(chapterCount);
|
||||||
|
for (let c = 1; c <= chapterCount; c++) {
|
||||||
|
chapters[c - 1] = getVerseCount(book.order, c);
|
||||||
|
}
|
||||||
|
return { bookId: book.id, chapters };
|
||||||
|
});
|
||||||
|
cached = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
return json(build());
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||||
|
import { getBookById } from '$lib/server/bible';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const { date } = body;
|
||||||
|
|
||||||
|
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = date;
|
||||||
|
|
||||||
|
const dailyVerse = await getVerseForDate(dateStr);
|
||||||
|
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
dailyVerse,
|
||||||
|
correctBookId: dailyVerse.bookId,
|
||||||
|
correctBook,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
|
||||||
|
|
||||||
|
// A spread of book IDs to use as fake guesses
|
||||||
|
const SAMPLE_BOOK_IDS = [
|
||||||
|
'GEN', 'EXO', 'PSA', 'PRO', 'ISA', 'JER', 'MAT', 'MRK', 'LUK', 'JHN',
|
||||||
|
'ROM', 'GAL', 'EPH', 'PHP', 'REV', 'ACT', 'HEB', 'JAS', '1CO', '2CO',
|
||||||
|
];
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { anonymousId, days = 10 } = await request.json();
|
||||||
|
|
||||||
|
if (!anonymousId || typeof anonymousId !== 'string') {
|
||||||
|
return json({ error: 'anonymousId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const inserted: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= days; i++) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||||
|
|
||||||
|
const guessCount = Math.floor(Math.random() * 6) + 1; // 1-6 guesses
|
||||||
|
// Pick `guessCount` random books (last one is the "correct" answer)
|
||||||
|
const shuffled = [...SAMPLE_BOOK_IDS].sort(() => Math.random() - 0.5);
|
||||||
|
const guesses = shuffled.slice(0, guessCount);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(dailyCompletions).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId,
|
||||||
|
date,
|
||||||
|
guessCount,
|
||||||
|
guesses: JSON.stringify(guesses),
|
||||||
|
completedAt: new Date(d.getTime() + 12 * 60 * 60 * 1000), // noon on that day
|
||||||
|
});
|
||||||
|
inserted.push(date);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
|
||||||
|
skipped.push(date);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true, inserted, skipped });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error seeding history:', err);
|
||||||
|
return json({ error: 'Failed to seed history' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyVerses, verseSubmissions, user } from '$lib/server/db/schema';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
import {
|
||||||
|
getChapterCount,
|
||||||
|
getVerseCount,
|
||||||
|
composeVerseWindow,
|
||||||
|
formatWindowReference
|
||||||
|
} from '$lib/server/xml-bible';
|
||||||
|
|
||||||
|
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
|
||||||
|
const MAX_SCAN_RETRIES = 5;
|
||||||
|
|
||||||
|
// Dev-only: insert a verse_submissions row (from a random existing account) +
|
||||||
|
// its corresponding daily_verses row, scheduled on the earliest valid future
|
||||||
|
// date per the spec's scheduling rules. Lets you populate /scheduled-verses to
|
||||||
|
// verify the admin view without going through the (Step 5+) submit API.
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const host = request.headers.get('host') ?? '';
|
||||||
|
if (!DEV_HOSTS.includes(host)) {
|
||||||
|
return json({ error: 'Not allowed in production' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a random existing user.
|
||||||
|
const users = await db.select().from(user);
|
||||||
|
if (users.length === 0) {
|
||||||
|
return json(
|
||||||
|
{ error: 'No users exist yet. Sign up at least one account first.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const submitter = users[Math.floor(Math.random() * users.length)];
|
||||||
|
|
||||||
|
// Pick a random valid (book, chapter, verse).
|
||||||
|
const book = bibleBooks[Math.floor(Math.random() * bibleBooks.length)];
|
||||||
|
const chapterCount = getChapterCount(book.order);
|
||||||
|
const chapter = Math.floor(Math.random() * chapterCount) + 1;
|
||||||
|
const verseCount = getVerseCount(book.order, chapter);
|
||||||
|
const verse = Math.floor(Math.random() * verseCount) + 1;
|
||||||
|
|
||||||
|
const window = composeVerseWindow(book.order, chapter, verse);
|
||||||
|
if (!window) {
|
||||||
|
return json({ error: 'Failed to compose verse window' }, { status: 500 });
|
||||||
|
}
|
||||||
|
const reference = formatWindowReference(
|
||||||
|
window.bookName,
|
||||||
|
window.startChapter,
|
||||||
|
window.startVerse,
|
||||||
|
window.endChapter,
|
||||||
|
window.endVerse
|
||||||
|
);
|
||||||
|
const verseText = window.verses.join(' ');
|
||||||
|
|
||||||
|
const todayUtc = new Date().toISOString().slice(0, 10);
|
||||||
|
const tomorrowMs = new Date(todayUtc + 'T00:00:00Z').getTime() + 86400000;
|
||||||
|
|
||||||
|
function addDays(ms: number, n: number): string {
|
||||||
|
return new Date(ms + n * 86400000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all future daily_verses into memory (date → bookId, date → reference).
|
||||||
|
const future = await db.select().from(dailyVerses).where(sql`date > ${todayUtc}`);
|
||||||
|
const byDate = new Map<string, { bookId: string; reference: string }>();
|
||||||
|
for (const r of future) byDate.set(r.date, { bookId: r.bookId, reference: r.reference });
|
||||||
|
|
||||||
|
// For the 60-day repeat rule, also need a bounded historical lookup per
|
||||||
|
// candidate date. Build a set of all existing references globally to do a
|
||||||
|
// cheap pre-check, then a precise range query when we settle on a date.
|
||||||
|
const allRows = await db.select().from(dailyVerses);
|
||||||
|
const refDates = new Map<string, string[]>(); // reference → list of dates
|
||||||
|
for (const r of allRows) {
|
||||||
|
const arr = refDates.get(r.reference) ?? [];
|
||||||
|
arr.push(r.date);
|
||||||
|
refDates.set(r.reference, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scheduledDate: string | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < MAX_SCAN_RETRIES && !scheduledDate; attempt++) {
|
||||||
|
// Walk day-by-day from tomorrow.
|
||||||
|
for (let offset = 0; offset < 10000; offset++) {
|
||||||
|
const D = addDays(tomorrowMs, offset);
|
||||||
|
|
||||||
|
// Rule 1: empty (no committed daily_verses row).
|
||||||
|
if (byDate.has(D)) continue;
|
||||||
|
|
||||||
|
// Rule 2: no back-to-back with prior neighbor.
|
||||||
|
const prev = byDate.get(addDays(tomorrowMs, offset - 1));
|
||||||
|
if (prev && prev.bookId === book.id) continue;
|
||||||
|
|
||||||
|
// Rule 3: no back-to-back with next committed neighbor.
|
||||||
|
const next = byDate.get(addDays(tomorrowMs, offset + 1));
|
||||||
|
if (next && next.bookId === book.id) continue;
|
||||||
|
|
||||||
|
// Rule 4: 60-day repeat distance — no same reference within ±60 days.
|
||||||
|
const sameRefDates = refDates.get(reference) ?? [];
|
||||||
|
const dMs = new Date(D + 'T00:00:00Z').getTime();
|
||||||
|
const tooClose = sameRefDates.some((d) => {
|
||||||
|
const diff = Math.abs(new Date(d + 'T00:00:00Z').getTime() - dMs);
|
||||||
|
return diff <= 60 * 86400000;
|
||||||
|
});
|
||||||
|
if (tooClose) continue;
|
||||||
|
|
||||||
|
scheduledDate = D;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!scheduledDate) break; // exhausted retries
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scheduledDate) {
|
||||||
|
return json({ error: 'Could not find a valid future date' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert both rows in a transaction. On a unique conflict (concurrent
|
||||||
|
// submission grabbed the same date), bubble up so the caller can retry.
|
||||||
|
const now = Date.now();
|
||||||
|
const verseId = Bun.randomUUIDv7();
|
||||||
|
const submissionId = Bun.randomUUIDv7();
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.insert(dailyVerses)
|
||||||
|
.values({
|
||||||
|
id: verseId,
|
||||||
|
date: scheduledDate!,
|
||||||
|
bookId: book.id,
|
||||||
|
verseText,
|
||||||
|
reference,
|
||||||
|
createdAt: sql`${Math.floor(now / 1000)}`
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
tx.insert(verseSubmissions)
|
||||||
|
.values({
|
||||||
|
id: submissionId,
|
||||||
|
userId: submitter.id,
|
||||||
|
scheduledDate: scheduledDate!,
|
||||||
|
selectedBookId: book.id,
|
||||||
|
selectedChapter: chapter,
|
||||||
|
selectedVerse: verse,
|
||||||
|
submittedAt: now
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
|
||||||
|
return json(
|
||||||
|
{ error: 'Date collision during seed; retry the request', detail: String(err?.message ?? err) },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
ok: true,
|
||||||
|
submitter: { id: submitter.id, email: submitter.email },
|
||||||
|
selected: {
|
||||||
|
bookId: book.id,
|
||||||
|
bookName: book.name,
|
||||||
|
chapter,
|
||||||
|
verse
|
||||||
|
},
|
||||||
|
scheduledDate,
|
||||||
|
reference,
|
||||||
|
verseText
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convenience: GET returns the current count of submissions + future scheduled
|
||||||
|
// verses, useful as a quick dev sanity check without mutating state.
|
||||||
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
const host = request.headers.get('host') ?? '';
|
||||||
|
if (!DEV_HOSTS.includes(host)) {
|
||||||
|
return json({ error: 'Not allowed in production' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const subs = await db.select().from(verseSubmissions);
|
||||||
|
const future = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyVerses)
|
||||||
|
.where(sql`date > ${new Date().toISOString().slice(0, 10)}`);
|
||||||
|
return json({
|
||||||
|
totalSubmissions: subs.length,
|
||||||
|
futureScheduledVerses: future.length
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const cronSecret = Bun.env.CRON_SECRET;
|
||||||
|
const discordWebhook = Bun.env.DISCORD_DAILY_WEBHOOK;
|
||||||
|
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
if (!authHeader || !cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||||
|
|
||||||
|
const verse = await getVerseForDate(dateStr);
|
||||||
|
|
||||||
|
const fullDate = new Date(dateStr + 'T00:00:00Z').toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = `*${fullDate}*\n**"${verse.verseText}"**`;
|
||||||
|
|
||||||
|
if (!discordWebhook) {
|
||||||
|
return json({ error: 'Discord webhook not configured' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordResponse = await fetch(discordWebhook, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!discordResponse.ok) {
|
||||||
|
return json({ error: 'Failed to post to Discord' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { and, eq, asc } from 'drizzle-orm';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const anonymousId = url.searchParams.get('anonymousId');
|
||||||
|
const date = url.searchParams.get('date');
|
||||||
|
|
||||||
|
if (!anonymousId || !date) {
|
||||||
|
return json({ error: 'Invalid data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(and(
|
||||||
|
eq(dailyCompletions.anonymousId, anonymousId),
|
||||||
|
eq(dailyCompletions.date, date)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userCompletions.length === 0) {
|
||||||
|
return json({ error: 'No completion found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCompletion = userCompletions[0];
|
||||||
|
const guessCount = userCompletion.guessCount;
|
||||||
|
|
||||||
|
const allCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.date, date))
|
||||||
|
.orderBy(asc(dailyCompletions.completedAt));
|
||||||
|
|
||||||
|
const totalSolves = allCompletions.length;
|
||||||
|
|
||||||
|
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||||
|
|
||||||
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
|
const guessRank = betterGuesses + 1;
|
||||||
|
|
||||||
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|
||||||
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
|
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||||
|
|
||||||
|
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||||
|
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||||
|
|
||||||
|
const guesses = userCompletion.guesses ? JSON.parse(userCompletion.guesses) : undefined;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile, guesses }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching stats:', err);
|
||||||
|
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const streakParam = url.searchParams.get('streak');
|
||||||
|
const localDate = url.searchParams.get('localDate');
|
||||||
|
|
||||||
|
if (!streakParam || !localDate) {
|
||||||
|
error(400, 'Missing streak or localDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStreak = parseInt(streakParam, 10);
|
||||||
|
if (isNaN(targetStreak) || targetStreak < 1) {
|
||||||
|
error(400, 'Invalid streak');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all completions ordered by anonymous_id and date desc
|
||||||
|
// so we can walk each user's history to compute their current streak.
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
|
date: dailyCompletions.date,
|
||||||
|
})
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
// Group dates by user
|
||||||
|
const byUser = new Map<string, string[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byUser.get(row.anonymousId);
|
||||||
|
if (list) {
|
||||||
|
list.push(row.date);
|
||||||
|
} else {
|
||||||
|
byUser.set(row.anonymousId, [row.date]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the current streak for each user.
|
||||||
|
// Start from today; if the user hasn't played today yet, try yesterday so
|
||||||
|
// that streaks aren't zeroed out mid-day before the player has had a chance
|
||||||
|
// to complete today's puzzle.
|
||||||
|
const yesterday = new Date(`${localDate}T00:00:00`);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayStr = yesterday.toLocaleDateString('en-CA');
|
||||||
|
|
||||||
|
const thirtyDaysAgo = new Date(`${localDate}T00:00:00`);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
const thirtyDaysAgoStr = thirtyDaysAgo.toLocaleDateString('en-CA');
|
||||||
|
|
||||||
|
// For each user, compute their current streak and whether they've played
|
||||||
|
// within the last 30 days. "Eligible players" = active streak OR recent play.
|
||||||
|
const userStats: { streak: number; isEligible: boolean }[] = [];
|
||||||
|
for (const [, dates] of byUser) {
|
||||||
|
// dates are already desc-sorted
|
||||||
|
const dateSet = new Set(dates);
|
||||||
|
|
||||||
|
// Pick the most recent anchor: today if played, otherwise yesterday
|
||||||
|
const anchor = dateSet.has(localDate) ? localDate : yesterdayStr;
|
||||||
|
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date(`${anchor}T00:00:00`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const dateStr = cursor.toLocaleDateString('en-CA');
|
||||||
|
if (!dateSet.has(dateStr)) break;
|
||||||
|
streak++;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRecentPlay = dates.some((d) => d >= thirtyDaysAgoStr);
|
||||||
|
userStats.push({ streak, isEligible: streak >= 1 || hasRecentPlay });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligiblePlayers = userStats.filter((u) => u.isEligible);
|
||||||
|
|
||||||
|
if (eligiblePlayers.length === 0) {
|
||||||
|
console.log('[streak-percentile] No eligible players found, returning 100th percentile');
|
||||||
|
return json({ percentile: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentage of eligible players who have a streak >= targetStreak
|
||||||
|
const atOrAbove = eligiblePlayers.filter((u) => u.streak >= targetStreak).length;
|
||||||
|
const raw = (atOrAbove / eligiblePlayers.length) * 100;
|
||||||
|
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
|
||||||
|
|
||||||
|
console.log('[streak-percentile]', {
|
||||||
|
localDate,
|
||||||
|
targetStreak,
|
||||||
|
totalUsers: byUser.size,
|
||||||
|
totalRows: rows.length,
|
||||||
|
eligiblePlayers: eligiblePlayers.length,
|
||||||
|
activeStreaks: userStats.filter((u) => u.streak >= 1).length,
|
||||||
|
recentPlayers: userStats.filter((u) => u.isEligible).length,
|
||||||
|
atOrAbove,
|
||||||
|
raw,
|
||||||
|
percentile,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ percentile });
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const anonymousId = url.searchParams.get('anonymousId');
|
||||||
|
const localDate = url.searchParams.get('localDate');
|
||||||
|
|
||||||
|
if (!anonymousId || !localDate) {
|
||||||
|
error(400, 'Missing anonymousId or localDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all completion dates for this user (stored as the user's local date)
|
||||||
|
const rows = await db
|
||||||
|
.select({ date: dailyCompletions.date })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
const completedDates = new Set(rows.map((r) => r.date));
|
||||||
|
|
||||||
|
// Subtract one calendar day from a YYYY-MM-DD string using UTC arithmetic —
|
||||||
|
// this avoids any dependence on the server's local timezone or DST offsets.
|
||||||
|
function prevDay(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() - 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk backwards from the user's local date, counting consecutive completed days
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = localDate;
|
||||||
|
|
||||||
|
while (completedDates.has(cursor)) {
|
||||||
|
streak++;
|
||||||
|
cursor = prevDay(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ streak: streak < 2 ? 0 : streak });
|
||||||
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyCompletions } from '$lib/server/db/schema';
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
import { and, eq, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const { anonymousId, date, guessCount } = await request.json();
|
const { anonymousId, date, guessCount, guesses } = await request.json();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
||||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
anonymousId,
|
anonymousId,
|
||||||
date,
|
date,
|
||||||
guessCount,
|
guessCount,
|
||||||
|
guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
|
||||||
completedAt,
|
completedAt,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -68,67 +69,3 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
return json({ error: 'Failed to submit completion' }, { status: 500 });
|
return json({ error: 'Failed to submit completion' }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
|
||||||
try {
|
|
||||||
const anonymousId = url.searchParams.get('anonymousId');
|
|
||||||
const date = url.searchParams.get('date');
|
|
||||||
|
|
||||||
if (!anonymousId || !date) {
|
|
||||||
return json({ error: 'Invalid data' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userCompletions = await db
|
|
||||||
.select()
|
|
||||||
.from(dailyCompletions)
|
|
||||||
.where(and(
|
|
||||||
eq(dailyCompletions.anonymousId, anonymousId),
|
|
||||||
eq(dailyCompletions.date, date)
|
|
||||||
))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (userCompletions.length === 0) {
|
|
||||||
return json({ error: 'No completion found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userCompletion = userCompletions[0];
|
|
||||||
const guessCount = userCompletion.guessCount;
|
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
const allCompletions = await db
|
|
||||||
.select()
|
|
||||||
.from(dailyCompletions)
|
|
||||||
.where(eq(dailyCompletions.date, date))
|
|
||||||
.orderBy(asc(dailyCompletions.completedAt));
|
|
||||||
|
|
||||||
const totalSolves = allCompletions.length;
|
|
||||||
|
|
||||||
// Solve rank: position in time-ordered list
|
|
||||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
|
||||||
|
|
||||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
|
||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
|
||||||
const guessRank = betterGuesses + 1;
|
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
|
||||||
|
|
||||||
// Average guesses
|
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
|
||||||
|
|
||||||
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
|
||||||
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
|
||||||
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
|
||||||
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching stats:', err);
|
|
||||||
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ cookies, request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const anonymousId = data.get('anonymousId')?.toString() || '';
|
||||||
|
|
||||||
|
// Generate CSRF state
|
||||||
|
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const state = Buffer.from(stateBytes).toString('base64url');
|
||||||
|
|
||||||
|
// Store state + anonymousId in a short-lived cookie
|
||||||
|
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||||
|
cookies.set('apple_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'none',
|
||||||
|
maxAge: 600
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, getAppleAuthUrl(state));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { exchangeAppleCode, decodeAppleIdToken } from '$lib/server/apple-auth';
|
||||||
|
import { env as publicEnv } from '$env/dynamic/public';
|
||||||
|
import * as auth from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user as userTable } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const code = formData.get('code')?.toString();
|
||||||
|
const state = formData.get('state')?.toString();
|
||||||
|
// Apple sends user info as JSON string on FIRST authorization only
|
||||||
|
const userInfoStr = formData.get('user')?.toString();
|
||||||
|
|
||||||
|
// Validate CSRF state
|
||||||
|
const storedRaw = cookies.get('apple_oauth_state');
|
||||||
|
if (!storedRaw || !state || !code) {
|
||||||
|
throw error(400, 'Invalid OAuth callback');
|
||||||
|
}
|
||||||
|
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||||
|
if (stored.state !== state) {
|
||||||
|
throw error(400, 'State mismatch');
|
||||||
|
}
|
||||||
|
cookies.delete('apple_oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
const anonId = stored.anonymousId;
|
||||||
|
if (!anonId) {
|
||||||
|
console.error('[Apple auth] Missing anonymousId in state cookie');
|
||||||
|
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
|
||||||
|
const claims = decodeAppleIdToken(tokens.id_token);
|
||||||
|
const appleId = claims.sub;
|
||||||
|
|
||||||
|
// Parse user info (only present on first authorization)
|
||||||
|
let appleFirstName: string | undefined;
|
||||||
|
let appleLastName: string | undefined;
|
||||||
|
if (userInfoStr) {
|
||||||
|
try {
|
||||||
|
const userInfo = JSON.parse(userInfoStr);
|
||||||
|
appleFirstName = userInfo.name?.firstName;
|
||||||
|
appleLastName = userInfo.name?.lastName;
|
||||||
|
} catch {
|
||||||
|
/* ignore parse errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User resolution ---
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
// 1. Check if a user with this appleId already exists (returning user)
|
||||||
|
const existingAppleUser = await auth.getUserByAppleId(appleId);
|
||||||
|
|
||||||
|
if (existingAppleUser) {
|
||||||
|
userId = existingAppleUser.id;
|
||||||
|
console.log(`[Apple auth] Returning Apple user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else if (claims.email) {
|
||||||
|
// 2. Check if email matches an existing email/password user
|
||||||
|
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||||
|
if (existingEmailUser) {
|
||||||
|
// Link Apple account to existing user
|
||||||
|
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||||
|
userId = existingEmailUser.id;
|
||||||
|
console.log(`[Apple auth] Linked Apple to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Apple auth] New user (has email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: claims.email,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId,
|
||||||
|
firstName: appleFirstName || null,
|
||||||
|
lastName: appleLastName || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Handle race condition: if appleId was inserted between our check and insert
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByAppleId(appleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Apple auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No email from Apple — create account with appleId only
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Apple auth] New user (no email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: null,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId,
|
||||||
|
firstName: appleFirstName || null,
|
||||||
|
lastName: appleLastName || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByAppleId(appleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Apple auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionToken = auth.generateSessionToken();
|
||||||
|
const session = await auth.createSession(sessionToken, userId);
|
||||||
|
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||||
|
|
||||||
|
redirect(302, '/');
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
|
||||||
|
let isOpen = $state(true);
|
||||||
|
const user = $derived(page.data.user);
|
||||||
|
let anonymousId = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (browser) {
|
||||||
|
anonymousId = localStorage.getItem("bibdle-anonymous-id") ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
{#if user}
|
||||||
|
<div class="text-white text-center space-y-4">
|
||||||
|
<p class="text-lg">
|
||||||
|
Signed in as <strong>{user.email ?? "no email"}</strong>
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="/auth/logout">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-red-600 rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (isOpen = true)}
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Open Auth Modal
|
||||||
|
</button>
|
||||||
|
<AuthModal bind:isOpen {anonymousId} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import { getGoogleAuthUrl } from '$lib/server/google-auth';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ cookies, request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const anonymousId = data.get('anonymousId')?.toString() || '';
|
||||||
|
|
||||||
|
// Generate CSRF state
|
||||||
|
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const state = Buffer.from(stateBytes).toString('base64url');
|
||||||
|
|
||||||
|
// sameSite 'lax' is safe here because Google sends a GET redirect back
|
||||||
|
// (unlike Apple which POSTs cross-origin, requiring 'none')
|
||||||
|
cookies.set('google_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 600
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, getGoogleAuthUrl(state));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { exchangeGoogleCode, decodeGoogleIdToken } from '$lib/server/google-auth';
|
||||||
|
import * as auth from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user as userTable } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const errorParam = url.searchParams.get('error');
|
||||||
|
|
||||||
|
// User denied access
|
||||||
|
if (errorParam) {
|
||||||
|
redirect(302, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedRaw = cookies.get('google_oauth_state');
|
||||||
|
if (!storedRaw || !state || !code) {
|
||||||
|
throw error(400, 'Invalid OAuth callback');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||||
|
if (stored.state !== state) {
|
||||||
|
throw error(400, 'State mismatch');
|
||||||
|
}
|
||||||
|
cookies.delete('google_oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
const anonId = stored.anonymousId;
|
||||||
|
if (!anonId) {
|
||||||
|
console.error('[Google auth] Missing anonymousId in state cookie');
|
||||||
|
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
const tokens = await exchangeGoogleCode(
|
||||||
|
code,
|
||||||
|
`${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`
|
||||||
|
);
|
||||||
|
const claims = decodeGoogleIdToken(tokens.id_token);
|
||||||
|
const googleId = claims.sub;
|
||||||
|
|
||||||
|
// --- User resolution ---
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
// 1. Check if a user with this googleId already exists (returning user)
|
||||||
|
const existingGoogleUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
|
||||||
|
if (existingGoogleUser) {
|
||||||
|
userId = existingGoogleUser.id;
|
||||||
|
console.log(`[Google auth] Returning Google user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else if (claims.email) {
|
||||||
|
// 2. Check if email matches an existing email/password or Apple user
|
||||||
|
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||||
|
if (existingEmailUser) {
|
||||||
|
// Link Google account to existing user
|
||||||
|
await db.update(userTable).set({ googleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||||
|
userId = existingEmailUser.id;
|
||||||
|
console.log(`[Google auth] Linked Google to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Google auth] New user (has email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: claims.email,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId: null,
|
||||||
|
googleId,
|
||||||
|
firstName: claims.given_name || null,
|
||||||
|
lastName: claims.family_name || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Handle race condition: if googleId was inserted between our check and insert
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Google auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No email from Google (edge case — Google almost always returns email)
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Google auth] New user (no email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: null,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId: null,
|
||||||
|
googleId,
|
||||||
|
firstName: claims.given_name || null,
|
||||||
|
lastName: claims.family_name || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Google auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionToken = auth.generateSessionToken();
|
||||||
|
const session = await auth.createSession(sessionToken, userId);
|
||||||
|
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||||
|
|
||||||
|
redirect(302, '/');
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import * as auth from '$lib/server/auth';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ locals, cookies }) => {
|
||||||
|
if (locals.session) {
|
||||||
|
await auth.invalidateSession(locals.session.id);
|
||||||
|
}
|
||||||
|
auth.deleteSessionTokenCookie({ cookies });
|
||||||
|
redirect(302, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import * as auth from '$lib/server/auth';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, cookies }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const email = data.get('email')?.toString();
|
||||||
|
const password = data.get('password')?.toString();
|
||||||
|
const anonymousId = data.get('anonymousId')?.toString();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return fail(400, { error: 'Email and password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return fail(400, { error: 'Please enter a valid email address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return fail(400, { error: 'Password must be at least 6 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user by email
|
||||||
|
const user = await auth.getUserByEmail(email);
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
return fail(400, { error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await auth.verifyPassword(password, user.passwordHash);
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return fail(400, { error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate anonymous stats if different anonymous ID
|
||||||
|
await auth.migrateAnonymousStats(anonymousId, user.id);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionToken = auth.generateSessionToken();
|
||||||
|
const session = await auth.createSession(sessionToken, user.id);
|
||||||
|
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign in error:', error);
|
||||||
|
return fail(500, { error: 'An error occurred during sign in' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { redirect, fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import * as auth from '$lib/server/auth';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, cookies }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const email = data.get('email')?.toString();
|
||||||
|
const password = data.get('password')?.toString();
|
||||||
|
const firstName = data.get('firstName')?.toString();
|
||||||
|
const lastName = data.get('lastName')?.toString();
|
||||||
|
const anonymousId = data.get('anonymousId')?.toString();
|
||||||
|
|
||||||
|
if (!email || !password || !anonymousId) {
|
||||||
|
return fail(400, { error: 'Email, password, and anonymous ID are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return fail(400, { error: 'Please enter a valid email address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return fail(400, { error: 'Password must be at least 6 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await auth.getUserByEmail(email);
|
||||||
|
if (existingUser) {
|
||||||
|
return fail(400, { error: 'An account with this email already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await auth.hashPassword(password);
|
||||||
|
|
||||||
|
// Create user with anonymousId as the user ID
|
||||||
|
const user = await auth.createUser(
|
||||||
|
anonymousId,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
firstName || undefined,
|
||||||
|
lastName || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionToken = auth.generateSessionToken();
|
||||||
|
const session = await auth.createSession(sessionToken, user.id);
|
||||||
|
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign up error:', error);
|
||||||
|
|
||||||
|
// Check if it's a unique constraint error (user with this ID already exists)
|
||||||
|
if (error instanceof Error && error.message.includes('UNIQUE constraint')) {
|
||||||
|
return fail(400, { error: 'This account is already registered. Please sign in instead.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return fail(500, { error: 'An error occurred during account creation' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user